Compare commits
69
Commits
799b7feb53
...
v1.0.91
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
32cf25ff73 | ||
|
|
032093e8c0 | ||
|
|
69a27784d1 | ||
|
|
1d93255575 | ||
|
|
05466a92b1 | ||
|
|
dd97413b8e | ||
|
|
2fa48335d3 | ||
|
|
bfe9852826 | ||
|
|
e3bc6cf491 | ||
|
|
fa6a5d0339 | ||
|
|
337af294f3 | ||
|
|
a4c68e5661 | ||
|
|
9f0269f591 | ||
|
|
b4f18f088b | ||
|
|
20f8dd82b6 | ||
|
|
d26d720e93 | ||
|
|
2796ebe381 | ||
|
|
9447d18323 | ||
|
|
9da69f4234 | ||
|
|
2c304dc363 | ||
|
|
02a394c99d | ||
|
|
b49859537b | ||
|
|
bed80b046b | ||
|
|
495a21b67b | ||
|
|
a32647ee16 | ||
|
|
08e1417393 | ||
|
|
70282fff73 | ||
|
|
43265dac0f | ||
|
|
66b90ca54b | ||
|
|
47108809aa | ||
|
|
720476fd6e | ||
|
|
572d03fe42 | ||
|
|
f1b9079b2d | ||
|
|
bb671c3680 | ||
|
|
bc8284db25 | ||
|
|
29e747393d | ||
|
|
df980a3434 | ||
|
|
778379538b | ||
|
|
7a9219b6e3 | ||
|
|
fd9c37cd25 | ||
|
|
0235a28add | ||
|
|
d961536186 | ||
|
|
d36806047b | ||
|
|
948419ffd3 | ||
|
|
b38f53c603 | ||
|
|
ccba53c494 | ||
|
|
53327b9a5a | ||
|
|
734b14d48d | ||
|
|
938f0ed51f | ||
|
|
6e2e7eb19e | ||
|
|
82e86cdf39 | ||
|
|
b7ae00ab52 | ||
|
|
8f8664357e | ||
|
|
7bd61a21b5 | ||
|
|
6f6b9834ca | ||
|
|
9cc10715e3 | ||
|
|
bbab5ce936 | ||
|
|
275424c605 | ||
|
|
b6c0cd3d1b | ||
|
|
d7d1d99ebc | ||
|
|
43849324ce | ||
|
|
e1df3b7cee | ||
|
|
f7b5d5f054 | ||
|
|
99fc4b0944 | ||
|
|
278d488363 | ||
|
|
2f69205aa0 | ||
|
|
b91985c493 | ||
|
|
80a3453233 | ||
|
|
ade71b75fa |
@@ -71,13 +71,34 @@ jobs:
|
||||
EXT=""
|
||||
[ "$GOOS" = "windows" ] && EXT=".exe"
|
||||
NAME="relspec-${GOOS}-${GOARCH}${EXT}"
|
||||
GOOS="$GOOS" GOARCH="$GOARCH" go build \
|
||||
GOOS="$GOOS" GOARCH="$GOARCH" CGO_ENABLED=0 go build \
|
||||
-trimpath \
|
||||
-ldflags "$LDFLAGS" \
|
||||
-o "$NAME" ./cmd/relspec
|
||||
echo "Built $NAME"
|
||||
if [ "$GOOS" = "linux" ]; then
|
||||
TARNAME="relspec_${VERSION#v}_${GOOS}_${GOARCH}.tar.gz"
|
||||
cp "$NAME" relspec
|
||||
tar czf "$TARNAME" relspec LICENSE
|
||||
rm relspec
|
||||
echo "Packed $TARNAME"
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Build Windows installer
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y nsis
|
||||
VERSION="${{ github.event.inputs.tag || github.ref_name }}"
|
||||
makensis -DVERSION="${VERSION#v}" -DEXE="$PWD/relspec-windows-amd64.exe" -DOUT="$PWD/relspec-setup-windows-amd64.exe" windows/installer.nsi
|
||||
|
||||
- name: Build source archives
|
||||
run: |
|
||||
TAG="${{ github.event.inputs.tag || github.ref_name }}"
|
||||
PREFIX="relspec-${TAG#v}"
|
||||
git archive --format=tar.gz --prefix="${PREFIX}/" -o "relspec-${TAG}-source.tar.gz" "$TAG"
|
||||
git archive --format=zip --prefix="${PREFIX}/" -o "relspec-${TAG}-source.zip" "$TAG"
|
||||
|
||||
- name: Create release and upload assets
|
||||
run: |
|
||||
TAG="${{ github.event.inputs.tag || github.ref_name }}"
|
||||
@@ -107,7 +128,7 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
for f in relspec-*; do
|
||||
for f in relspec-* relspec_*.tar.gz; do
|
||||
echo "Uploading $f..."
|
||||
curl -s -X POST "${UPLOAD_URL}?name=${f}" \
|
||||
-H "Authorization: token ${GITHUB_TOKEN}" \
|
||||
@@ -118,8 +139,13 @@ jobs:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
pkg-aur:
|
||||
name: AUR package (${{ matrix.pkg }})
|
||||
needs: release
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
pkg: [relspec, relspec-bin]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -131,6 +157,7 @@ jobs:
|
||||
|
||||
VERSION="${{ github.event.inputs.tag || github.ref_name }}"
|
||||
PKGVER="${VERSION#v}"
|
||||
PKG="${{ matrix.pkg }}"
|
||||
AUR_KEY_PATH="$HOME/.ssh/aur"
|
||||
AUR_KNOWN_HOSTS="$HOME/.ssh/known_hosts"
|
||||
|
||||
@@ -169,10 +196,15 @@ jobs:
|
||||
|
||||
# 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
|
||||
git clone "ssh://aur@aur.archlinux.org/${PKG}.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 [ -f aur-repo/PKGBUILD ]; then
|
||||
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:]")
|
||||
else
|
||||
CURRENT_PKGVER=""
|
||||
CURRENT_PKGREL=0
|
||||
fi
|
||||
|
||||
if [ "$CURRENT_PKGVER" = "$PKGVER" ]; then
|
||||
case "$CURRENT_PKGREL" in
|
||||
@@ -188,16 +220,25 @@ jobs:
|
||||
PKGREL=1
|
||||
fi
|
||||
|
||||
echo "Publishing AUR package version ${PKGVER}-${PKGREL}"
|
||||
echo "Publishing AUR package ${PKG} ${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)
|
||||
# Compute SHA256 of the artifacts from the same URLs the PKGBUILD will download.
|
||||
DL="https://git.warky.dev/wdevs/relspecgo/releases/download/v${PKGVER}"
|
||||
if [ "$PKG" = "relspec-bin" ]; then
|
||||
SHA_AMD64=$(curl -fsSL "${DL}/relspec_${PKGVER}_linux_amd64.tar.gz" | sha256sum | cut -d' ' -f1)
|
||||
SHA_ARM64=$(curl -fsSL "${DL}/relspec_${PKGVER}_linux_arm64.tar.gz" | sha256sum | cut -d' ' -f1)
|
||||
SUMS=(-e "s/^sha256sums_x86_64=.*/sha256sums_x86_64=('${SHA_AMD64}')/" \
|
||||
-e "s/^sha256sums_aarch64=.*/sha256sums_aarch64=('${SHA_ARM64}')/")
|
||||
else
|
||||
SHA=$(curl -fsSL "${DL}/relspec-v${PKGVER}-source.zip" | sha256sum | cut -d' ' -f1)
|
||||
SUMS=(-e "s/^sha256sums=.*/sha256sums=('${SHA}')/")
|
||||
fi
|
||||
|
||||
# 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
|
||||
"${SUMS[@]}" \
|
||||
"linux/arch/${PKG}/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)
|
||||
|
||||
+59
-113
@@ -1,145 +1,91 @@
|
||||
# Contributing to RelSpec
|
||||
|
||||
Thank you for your interest in contributing to RelSpec.
|
||||
## Setup
|
||||
|
||||
## Development Setup
|
||||
- Go 1.25+ (see `go.mod`), Git
|
||||
- Optional: golangci-lint, Docker/Podman (PostgreSQL integration tests)
|
||||
|
||||
### Prerequisites
|
||||
- Go 1.21 or higher
|
||||
- Git
|
||||
- (Optional) golangci-lint for linting
|
||||
- (Optional) Docker for database testing
|
||||
|
||||
### Getting Started
|
||||
|
||||
1. Clone the repository:
|
||||
```bash
|
||||
git clone https://github.com/wdevs/relspecgo.git
|
||||
git clone git@git.warky.dev:wdevs/relspecgo.git
|
||||
cd relspecgo
|
||||
make deps
|
||||
make build # outputs build/relspec
|
||||
```
|
||||
|
||||
2. Install dependencies:
|
||||
```bash
|
||||
go mod download
|
||||
```
|
||||
## Make Targets
|
||||
|
||||
3. Run tests:
|
||||
```bash
|
||||
go test ./...
|
||||
```
|
||||
| Target | Purpose |
|
||||
|-------------------------|------------------------------------------------------|
|
||||
| `make test` | Unit tests (race detection, coverage) |
|
||||
| `make test-integration` | Integration tests (needs `RELSPEC_TEST_PG_CONN`) |
|
||||
| `make docker-test` | PostgreSQL integration tests via Docker/Podman |
|
||||
| `make lint` | golangci-lint |
|
||||
| `make fmt` / `fmt-check`| gofumpt + goimports |
|
||||
| `make check` | vet, fmt-check, staticcheck, govulncheck |
|
||||
| `make coverage` | Coverage report |
|
||||
|
||||
4. Build the project:
|
||||
```bash
|
||||
go build -o relspec ./cmd/relspec
|
||||
```
|
||||
Single test: `go test -run TestName ./pkg/readers/dbml`
|
||||
|
||||
## Project Structure
|
||||
## Layout
|
||||
|
||||
```
|
||||
relspecgo/
|
||||
├── cmd/ # CLI application entry point
|
||||
├── pkg/
|
||||
│ ├── readers/ # Input format readers (XML, JSON, DCTX, DB, GORM, Bun)
|
||||
│ ├── writers/ # Output format writers (GORM, Bun, JSON, YAML)
|
||||
│ ├── models/ # Internal data models for relations
|
||||
│ └── transform/ # Transformation and validation logic
|
||||
├── examples/ # Usage examples and sample files
|
||||
├── tests/ # Integration tests
|
||||
└── .claude/ # Claude Code configuration and commands
|
||||
cmd/relspec/ CLI commands (convert, diff, merge, split, edit, inspect, job, ...)
|
||||
pkg/models/ Core model: Database > Schema > Table > Column/Constraint/Index/Relationship
|
||||
pkg/readers/<fmt> One reader per format
|
||||
pkg/writers/<fmt> One writer per format
|
||||
pkg/diff, merge, inspector, jobs, transform, ui, pgsql, sqltypes ...
|
||||
examples/ Sample files
|
||||
tests/ Integration tests and assets
|
||||
docs/ Feature docs
|
||||
```
|
||||
|
||||
## Adding New Readers
|
||||
## Adding a Reader
|
||||
|
||||
To add a new input format reader:
|
||||
1. Create `pkg/readers/<format>/reader.go` with `NewReader(options *readers.ReaderOptions)`.
|
||||
2. Implement `readers.Reader`: `ReadDatabase`, `ReadSchema`, `ReadTable`.
|
||||
3. Add `reader_test.go` in the same package.
|
||||
4. Register the format in the CLI switches (`cmd/relspec/convert.go`, `diff.go`, `edit.go`, ...).
|
||||
5. Add a `README.md` in the reader directory.
|
||||
|
||||
1. Create a new file in `pkg/readers/` (e.g., `myformat_reader.go`)
|
||||
2. Implement the `Reader` interface:
|
||||
```go
|
||||
type Reader interface {
|
||||
Read(source string) (*models.Schema, error)
|
||||
}
|
||||
```
|
||||
3. Add tests in `pkg/readers/myformat_reader_test.go`
|
||||
4. Register the reader in the CLI
|
||||
## Adding a Writer
|
||||
|
||||
## Adding New Writers
|
||||
1. Create `pkg/writers/<format>/writer.go` with `NewWriter(options *writers.WriterOptions)`.
|
||||
2. Implement `writers.Writer`: `WriteDatabase`, `WriteSchema`, `WriteTable`.
|
||||
3. Add `writer_test.go` in the same package.
|
||||
4. Register the format in the CLI switches.
|
||||
5. Add a `README.md` in the writer directory.
|
||||
|
||||
To add a new output format writer:
|
||||
## Code Rules
|
||||
|
||||
1. Create a new file in `pkg/writers/` (e.g., `myformat_writer.go`)
|
||||
2. Implement the `Writer` interface:
|
||||
```go
|
||||
type Writer interface {
|
||||
Write(schema *models.Schema, destination string) error
|
||||
}
|
||||
```
|
||||
3. Add tests in `pkg/writers/myformat_writer_test.go`
|
||||
4. Register the writer in the CLI
|
||||
|
||||
## Code Style
|
||||
|
||||
- Follow standard Go conventions
|
||||
- Use `gofmt` for formatting
|
||||
- Run `go vet` to check for issues
|
||||
- Use meaningful variable and function names
|
||||
- Add comments for exported functions and types
|
||||
- Format with gofumpt/goimports (`make fmt`); `make check` must pass.
|
||||
- Iterate `Table.Columns`, `Constraints`, `Indexes`, `Relationships` in sorted order (maps are unordered; output must be deterministic).
|
||||
- Every writer stamps `buildinfo.GeneratedComment()` in its file header.
|
||||
- Comment exported functions and types.
|
||||
|
||||
## Testing
|
||||
|
||||
- Write unit tests for all new functionality
|
||||
- Aim for >80% code coverage
|
||||
- Use table-driven tests where appropriate
|
||||
- Include both positive and negative test cases
|
||||
- Tests live in the same package as the code.
|
||||
- Table-driven tests; cover positive and negative cases.
|
||||
- Reuse existing test data in `tests/` and `examples/` before adding new data.
|
||||
- Tests run with `-race`.
|
||||
|
||||
### Running Tests
|
||||
## Commits
|
||||
|
||||
```bash
|
||||
# All tests
|
||||
go test ./...
|
||||
- Format: `type(scope): description`
|
||||
- Types: `feat`, `fix`, `docs`, `test`, `refactor`, `chore`, `ci`
|
||||
- Keep commits focused. Reference issues where applicable.
|
||||
|
||||
# With coverage
|
||||
go test -cover ./...
|
||||
## Pull Requests
|
||||
|
||||
# Verbose output
|
||||
go test -v ./...
|
||||
1. Branch from `master`.
|
||||
2. Add tests; `make test` and `make check` pass.
|
||||
3. Update docs/README if behaviour changes.
|
||||
4. Open a PR with a clear description.
|
||||
|
||||
# Specific package
|
||||
go test ./pkg/readers/...
|
||||
```
|
||||
## Security
|
||||
|
||||
## Committing Changes
|
||||
|
||||
- Write clear, descriptive commit messages
|
||||
- Follow conventional commits format: `type(scope): description`
|
||||
- Types: feat, fix, docs, test, refactor, chore
|
||||
- Example: `feat(readers): add PostgreSQL support`
|
||||
- Keep commits focused and atomic
|
||||
- Reference issues in commit messages when applicable
|
||||
|
||||
## Pull Request Process
|
||||
|
||||
1. Create a feature branch from `master`
|
||||
2. Make your changes
|
||||
3. Add tests for new functionality
|
||||
4. Ensure all tests pass
|
||||
5. Update documentation if needed
|
||||
6. Submit a pull request with a clear description
|
||||
|
||||
## Claude Code Commands
|
||||
|
||||
This project includes Claude Code slash commands for common tasks:
|
||||
|
||||
- `/test` - Run all tests
|
||||
- `/build` - Build the binary
|
||||
- `/lint` - Run linters
|
||||
- `/coverage` - Generate coverage report
|
||||
|
||||
## Questions or Issues?
|
||||
|
||||
- Open an issue for bugs or feature requests
|
||||
- Start a discussion for questions or ideas
|
||||
- Check existing issues before creating new ones
|
||||
Do not report vulnerabilities in public issues. See [SECURITY.md](SECURITY.md).
|
||||
|
||||
## License
|
||||
|
||||
By contributing to RelSpec, you agree that your contributions will be licensed under the Apache License 2.0.
|
||||
Contributions are licensed under the Apache License 2.0.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
.PHONY: all build test test-unit test-integration lint coverage clean install help docker-up docker-down docker-test docker-test-integration start stop release release-version godoc vet fmt fmt-check staticcheck govulncheck check
|
||||
.PHONY: installer-windows all build test test-unit test-integration lint coverage clean install help docker-up docker-down docker-test docker-test-integration start stop release release-version rerelease godoc vet fmt fmt-check staticcheck govulncheck check
|
||||
|
||||
# Binary name
|
||||
BINARY_NAME=relspec
|
||||
@@ -16,8 +16,9 @@ GOCLEAN=$(GOCMD) clean
|
||||
|
||||
# Tool versions (compiled on demand via `go run` so they match the local toolchain)
|
||||
GOLANGCI_LINT = go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest
|
||||
STATICCHECK = go run honnef.co/go/tools/cmd/staticcheck@latest
|
||||
GOVULNCHECK = go run golang.org/x/vuln/cmd/govulncheck@latest
|
||||
STATICCHECK = build/tools/staticcheck
|
||||
# Scan with the toolchain pinned in go.mod (what CI uses), so stdlib vulns match CI.
|
||||
GOVULNCHECK = GOTOOLCHAIN=go$(shell awk '/^go /{print $$2; exit}' go.mod) go run golang.org/x/vuln/cmd/govulncheck@latest
|
||||
|
||||
# Version information
|
||||
# VERSION file is the single source of truth (bumped by `make release-version`).
|
||||
@@ -63,7 +64,16 @@ fmt-check: ## Check formatting (gofumpt + goimports via golangci-lint)
|
||||
@diff=$$($(GOLANGCI_LINT) fmt --diff --config=.golangci.json); \
|
||||
if [ -n "$$diff" ]; then echo "$$diff"; echo "Run: make fmt"; exit 1; fi
|
||||
|
||||
staticcheck: ## Run staticcheck
|
||||
# staticcheck is built against the latest golang.org/x/tools so it can read export
|
||||
# data from the newest Go toolchain (`go run ...@latest` uses an older, pinned x/tools).
|
||||
$(STATICCHECK):
|
||||
@echo "Building staticcheck..."
|
||||
@mkdir -p build/tools/src
|
||||
@cd build/tools/src && { [ -f go.mod ] || go mod init staticcheck-build >/dev/null 2>&1; } && \
|
||||
go get honnef.co/go/tools/cmd/staticcheck@latest golang.org/x/tools@latest && \
|
||||
go build -o ../staticcheck honnef.co/go/tools/cmd/staticcheck
|
||||
|
||||
staticcheck: $(STATICCHECK) ## Run staticcheck
|
||||
@echo "Running staticcheck..."
|
||||
$(STATICCHECK) ./...
|
||||
|
||||
@@ -209,7 +219,7 @@ docker-test-integration: docker-up ## Start DB and run integration tests
|
||||
$(GOTEST) -v ./pkg/readers/pgsql/ -count=1 || (make docker-down && exit 1)
|
||||
@make docker-down
|
||||
|
||||
release: lint fmt-check test build ## Run lint, format check, tests, build, then create and push a new release tag
|
||||
release: lint fmt-check govulncheck test build ## Run lint, format check, govulncheck, tests, build, then create and push a new release tag
|
||||
@echo "Creating new release..."
|
||||
@latest_tag=$$(git describe --tags --abbrev=0 2>/dev/null || echo ""); \
|
||||
if [ -z "$$latest_tag" ]; then \
|
||||
@@ -228,9 +238,9 @@ release: lint fmt-check test build ## Run lint, format check, tests, build, then
|
||||
PKGVER=$${version#v}; \
|
||||
echo "Updating package metadata to $$PKGVER..."; \
|
||||
echo "$$PKGVER" > VERSION; \
|
||||
sed -i "s/^pkgver=.*/pkgver=$$PKGVER/" linux/arch/PKGBUILD; \
|
||||
sed -i "s/^pkgver=.*/pkgver=$$PKGVER/" linux/arch/relspec/PKGBUILD linux/arch/relspec-bin/PKGBUILD; \
|
||||
sed -i "s/^Version:.*/Version: $$PKGVER/" linux/centos/relspec.spec; \
|
||||
git add VERSION linux/arch/PKGBUILD linux/centos/relspec.spec; \
|
||||
git add VERSION linux/arch/relspec/PKGBUILD linux/arch/relspec-bin/PKGBUILD linux/centos/relspec.spec; \
|
||||
if ! git diff --cached --quiet; then \
|
||||
git commit -m "chore(release): update package version to $$PKGVER"; \
|
||||
fi; \
|
||||
@@ -243,7 +253,7 @@ release: lint fmt-check test build ## Run lint, format check, tests, build, then
|
||||
git push origin "$$version"; \
|
||||
echo "Tag $$version created and pushed to remote repository."
|
||||
|
||||
release-version: ## Auto-increment patch version, update VERSION/package files, commit, tag, and push
|
||||
release-version: lint fmt-check staticcheck govulncheck ## Run lint, format check, staticcheck and govulncheck, then auto-increment patch version, update VERSION/package files, commit, tag, and push
|
||||
@CURRENT=$$([ -f VERSION ] && cat VERSION | tr -d '[:space:]' || (git describe --tags --abbrev=0 2>/dev/null | sed 's/^v//') || echo "0.0.0"); \
|
||||
MAJOR=$$(echo $$CURRENT | cut -d. -f1); \
|
||||
MINOR=$$(echo $$CURRENT | cut -d. -f2); \
|
||||
@@ -252,13 +262,26 @@ release-version: ## Auto-increment patch version, update VERSION/package files,
|
||||
NEXT="v$$PKGVER"; \
|
||||
echo "Current: v$$CURRENT → Next: $$NEXT"; \
|
||||
echo "$$PKGVER" > VERSION; \
|
||||
sed -i "s/^pkgver=.*/pkgver=$$PKGVER/" linux/arch/PKGBUILD; \
|
||||
sed -i "s/^pkgver=.*/pkgver=$$PKGVER/" linux/arch/relspec/PKGBUILD linux/arch/relspec-bin/PKGBUILD; \
|
||||
sed -i "s/^Version:.*/Version: $$PKGVER/" linux/centos/relspec.spec; \
|
||||
git add VERSION linux/arch/PKGBUILD linux/centos/relspec.spec; \
|
||||
git add VERSION linux/arch/relspec/PKGBUILD linux/arch/relspec-bin/PKGBUILD linux/centos/relspec.spec; \
|
||||
git commit -m "chore(release): update package version to $$PKGVER"; \
|
||||
git tag -a "$$NEXT" -m "Release $$NEXT"; \
|
||||
git push origin HEAD "$$NEXT"; \
|
||||
echo "Pushed $$NEXT — release workflow triggered"
|
||||
|
||||
rerelease: lint fmt-check govulncheck ## Move the latest tag to HEAD and force push it
|
||||
@TAG=$$(git describe --tags --abbrev=0 2>/dev/null); \
|
||||
if [ -z "$$TAG" ]; then echo "No existing tags found"; exit 1; fi; \
|
||||
echo "Moving $$TAG to $$(git rev-parse --short HEAD)"; \
|
||||
git tag -f -a "$$TAG" -m "Release $$TAG" HEAD; \
|
||||
git push --force origin "$$TAG"; \
|
||||
echo "Pushed $$TAG — release workflow triggered"
|
||||
|
||||
help: ## Display this help screen
|
||||
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}'
|
||||
|
||||
installer-windows: ## Build the Windows binary and NSIS installer (requires makensis)
|
||||
@echo "Building Windows installer..."
|
||||
GOOS=windows GOARCH=amd64 $(GOBUILD) -trimpath -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/relspec-windows-amd64.exe ./cmd/relspec
|
||||
makensis -DVERSION=$$(cat VERSION | tr -d '[:space:]') -DEXE=$(CURDIR)/$(BUILD_DIR)/relspec-windows-amd64.exe -DOUT=$(CURDIR)/$(BUILD_DIR)/relspec-setup-windows-amd64.exe windows/installer.nsi
|
||||
|
||||
@@ -16,12 +16,18 @@
|
||||
go install -v git.warky.dev/wdevs/relspecgo/cmd/relspec@latest
|
||||
```
|
||||
|
||||
Windows: download `relspec-setup-windows-amd64.exe` from the
|
||||
[latest release](https://git.warky.dev/wdevs/relspecgo/releases/latest)
|
||||
(installs to Program Files and adds `relspec` to `PATH`). See [windows/README.md](windows/README.md).
|
||||
|
||||
## Supported Formats
|
||||
|
||||
| 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` |
|
||||
| **Readers** | `bun` `dbml` `dctx` `drawdb` `drizzle` `gorm` `graphql` `json` `mssql` `mysql` `pgsql` `prisma` `sqldir` `sqlite` `typeorm` `yaml` |
|
||||
| **Writers** | `bun` `dbml` `dctx` `drawdb` `drizzle` `gorm` `graphql` `json` `mssql` `mysql` `pgsql` `prisma` `sqlexec` `sqlite` `template` `typeorm` `yaml` |
|
||||
|
||||
See [docs/FORMAT_EXAMPLES.md](docs/FORMAT_EXAMPLES.md) for usage examples covering every format.
|
||||
|
||||
## Commands
|
||||
|
||||
@@ -35,11 +41,72 @@ relspec convert --from pgsql --from-conn "postgres://user:pass@localhost/mydb" \
|
||||
# DBML → PostgreSQL DDL
|
||||
relspec convert --from dbml --from-path schema.dbml --to pgsql --to-path schema.sql
|
||||
|
||||
# Lowercase model names and structured references before output
|
||||
relspec convert --from dbml --from-path schema.dbml --to pgsql --to-path schema.sql --lowercase
|
||||
|
||||
# PostgreSQL → SQLite (auto flattens schemas)
|
||||
relspec convert --from pgsql --from-conn "postgres://..." --to sqlite --to-path schema.sql
|
||||
|
||||
# Multiple input files merged
|
||||
relspec convert --from json --from-list "a.json,b.json" --to yaml --to-path merged.yaml
|
||||
|
||||
# Watch mode: regenerate whenever the source file(s) change (Ctrl-C to stop)
|
||||
relspec convert --from dbml --from-path schema.dbml --to gorm --to-path models/ --package models --watch
|
||||
```
|
||||
|
||||
`--watch` works with `--from-path` and `--from-list` (not live database
|
||||
connections or `--dry-run`). Source files are polled every `--watch-interval`
|
||||
(default 500ms), a directory source is watched recursively, and the output path
|
||||
is ignored so generating into the source tree does not loop. Conversion errors
|
||||
are printed and watching continues.
|
||||
|
||||
#### Identifier casing and PostgreSQL quoting
|
||||
|
||||
PostgreSQL output always double-quotes model identifiers, including reserved
|
||||
words, and preserves their case by default. For example, schema `Sales` and table
|
||||
`Order` produce `"Sales"."Order"`; with `--lowercase`, they produce
|
||||
`"sales"."order"`. Quoted names are case-sensitive in PostgreSQL.
|
||||
|
||||
`--lowercase` is available on `convert`, `batch`, `merge`, `split`, `templ`, and
|
||||
`job run`. It lowercases model names and structured references on a copy and
|
||||
rejects collisions such as `Users` and `users` in the same schema. SQL expressions,
|
||||
defaults, comments, enum values, and data retain their case. Adjust identifier
|
||||
references inside your own SQL expressions when opting into lowercasing.
|
||||
|
||||
Generated PostgreSQL SQL also escapes identifier quotes and literal values and
|
||||
chooses dollar-quote delimiters that do not collide with function or procedural
|
||||
block contents. Raw SQL expressions remain executable input. See the
|
||||
[PostgreSQL writer documentation](pkg/writers/pgsql/README.md#identifier-and-value-handling)
|
||||
for library options and details.
|
||||
|
||||
#### MySQL fidelity and strict conversion
|
||||
|
||||
MySQL output now preserves foreign keys and their actions, checks, indexes,
|
||||
unsigned types, dimensions, generated columns, and native defaults. Known
|
||||
unsupported features produce warnings. Add `--strict-conversion` to reject
|
||||
lossy MySQL output before writing a file or executing DDL:
|
||||
|
||||
```bash
|
||||
relspec convert --from mysql --from-conn "$MYSQL_DSN" \
|
||||
--to mysql --to-path schema.sql --strict-conversion
|
||||
```
|
||||
|
||||
Schema jobs can set `options.strict_conversion: true`. This check currently
|
||||
covers MySQL output; it does not guarantee lossless conversion to every format.
|
||||
The live suite covers MySQL 8.4 / InnoDB, including JSON/YAML intermediates.
|
||||
See the [MySQL capability matrix](docs/MYSQL_FIDELITY.md) for unsupported objects,
|
||||
metadata, SQL-mode requirements, and execution limits. Output creates tables in
|
||||
existing databases; it is not a data copy or a rerunnable migration.
|
||||
|
||||
### `batch` — Convert many inputs in one run
|
||||
|
||||
Converts each input independently (one output per input, unlike `--from-list`
|
||||
which merges). `--input` takes paths or globs; outputs go to `--to-dir`.
|
||||
Use `--keep-going` to continue past failures (exit is still non-zero) and
|
||||
`--dry-run` to validate without writing. For named workflows see `relspec job run`.
|
||||
|
||||
```bash
|
||||
relspec batch --from dbml --input "schemas/*.dbml" --to json --to-dir out/
|
||||
```
|
||||
|
||||
PostgreSQL connections opened by relspec set `application_name` by default to
|
||||
@@ -139,6 +206,7 @@ jobs:
|
||||
overwrite: true
|
||||
options:
|
||||
flatten_schema: false
|
||||
lowercase_identifiers: true # opt in; defaults to false
|
||||
logfile: .relspec/log/build-schema.log
|
||||
```
|
||||
|
||||
@@ -151,6 +219,40 @@ any job runs. Path-like fields also support `${NAME}` environment-variable
|
||||
references, which are expanded and safety-checked during pre-flight. See
|
||||
[docs/JOB_FILES.md](docs/JOB_FILES.md).
|
||||
|
||||
Use `options.lowercase_identifiers: true` per job, or
|
||||
`relspec job run build-schema --lowercase` to enable lowercasing for schema outputs
|
||||
throughout the selected job plan. For MySQL schema output,
|
||||
`options.strict_conversion: true` or `--strict-conversion` rejects known lossy
|
||||
conversions before writing.
|
||||
|
||||
### `scripts` — SQL scripts and embedded assets
|
||||
|
||||
```bash
|
||||
relspec scripts list --dir ./migrations
|
||||
relspec scripts execute --dir ./migrations --conn "$DATABASE_URL"
|
||||
```
|
||||
|
||||
Execution stops on the first error by default. `--continue-on-error` (alias
|
||||
`--coe`) attempts later scripts and still exits nonzero if any script fails.
|
||||
|
||||
Scripts can embed nearby files using a standalone directive comment:
|
||||
|
||||
```sql
|
||||
-- @embed: path=assets/message.txt var=:message mode=text
|
||||
INSERT INTO messages (body) VALUES (:message);
|
||||
```
|
||||
|
||||
Embed replacement preserves dollar text such as `$1`, `${2}`, and `$HOME` exactly.
|
||||
Placeholders are replaced only in executable SQL; quoted strings, quoted
|
||||
identifiers, comments, casts, and dollar-quoted bodies retain their contents.
|
||||
Inserted content is never scanned for further placeholders. Asset-call named
|
||||
parameters follow the same SQL lexical boundaries. See
|
||||
[docs/SCRIPTS_COMMAND.md](docs/SCRIPTS_COMMAND.md) for file containment, limits,
|
||||
binary embedding, and execution details.
|
||||
|
||||
MySQL DDL execution keeps each generated statement whole, preserving multiline
|
||||
defaults containing semicolons or comment-like text.
|
||||
|
||||
### `edit` — Interactive TUI editor
|
||||
|
||||
```bash
|
||||
@@ -172,6 +274,16 @@ relspec edit --from pgsql --from-conn "postgres://user:pass@localhost/mydb" \
|
||||
<img src="./assets/image/screenshots/edit_column.jpg">
|
||||
</p>
|
||||
|
||||
### `update` — Check for a newer release
|
||||
|
||||
```bash
|
||||
relspec update # check, prompt, then download + run installer (Windows)
|
||||
relspec update --check # report only
|
||||
relspec update --yes # skip the prompt
|
||||
```
|
||||
|
||||
Non-Windows platforms print the release URL. `dev` and commit-hash builds are never reported as outdated.
|
||||
|
||||
## Development
|
||||
|
||||
**Prerequisites:** Go 1.24.0+
|
||||
@@ -182,6 +294,7 @@ make test # race detection + coverage
|
||||
make lint # requires golangci-lint
|
||||
make coverage # → coverage.html
|
||||
make install # → $GOPATH/bin
|
||||
make installer-windows # → build/relspec-setup-windows-amd64.exe (needs makensis)
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
@@ -196,6 +309,8 @@ pkg/merge/ Schema merging
|
||||
pkg/models/ Internal data models
|
||||
pkg/transform/ Transformation logic
|
||||
pkg/pgsql/ PostgreSQL utilities
|
||||
pkg/updatecheck/ Latest-release lookup and version comparison
|
||||
windows/ NSIS installer script
|
||||
pkg/sqltypes/ Nullable SQL types for generated/hand-written models (see below)
|
||||
```
|
||||
|
||||
@@ -216,6 +331,23 @@ see [`bun`'s `--array-nullable`](./pkg/writers/bun/README.md#nullablearrays)
|
||||
flag for nullable-array handling. The `SqlXxxArray` wrapper types remain
|
||||
available in `pkg/sqltypes` and are still used by the `gorm` writer.
|
||||
|
||||
#### Custom type mapping
|
||||
|
||||
Override the built-in SQL → Go mapping of the `bun` and `gorm` writers with the
|
||||
repeatable `--type-map sqltype=gotype` flag:
|
||||
|
||||
```bash
|
||||
relspec convert --from pgsql --from-conn "$DSN" --to gorm --to-path models.go \
|
||||
--type-map uuid=string --type-map jsonb=json.RawMessage
|
||||
```
|
||||
|
||||
SQL type names are matched case-insensitively on the base type (modifiers such
|
||||
as `(10,2)` are ignored; aliases like `int4` resolve to `integer`). NOT NULL
|
||||
columns use the Go type verbatim, nullable columns get a `*` prefix (unless the
|
||||
type is already a pointer, slice, map or `any`), and arrays become `[]gotype`.
|
||||
Unmapped types keep their defaults. The flag does not add imports: use types
|
||||
that need none, or add the import afterwards (e.g. with `goimports`).
|
||||
|
||||
## Contributing
|
||||
|
||||
1. Register or sign in with GitHub at [git.warky.dev](https://git.warky.dev)
|
||||
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
# Security Policy
|
||||
|
||||
## Supported versions
|
||||
Security fixes are released for the latest minor version of RelSpec.
|
||||
|
||||
## Reporting a vulnerability
|
||||
Please do not open a public issue for security problems.
|
||||
|
||||
Report privately by email: warkydevs@gmail.com
|
||||
|
||||
Reports are reviewed on a best-effort basis. No response time or fix timeline is
|
||||
guaranteed. Reporters may be credited in the release notes if they wish.
|
||||
@@ -4,20 +4,25 @@
|
||||
|
||||
- [✔️] **Database Inspector**
|
||||
- [✔️] PostgreSQL driver (reader + writer)
|
||||
- [ ] MySQL driver
|
||||
- [ ] MySQL driver (only MariaDB datatype conversion in pkg/mariadb, no reader/writer)
|
||||
- [✔️] SQLite driver (reader + writer with automatic schema flattening)
|
||||
- [ ] MSSQL driver
|
||||
- [✔️] MSSQL driver (reader + writer, generated and identity columns)
|
||||
- [✔️] Foreign key detection
|
||||
- [✔️] Index extraction
|
||||
- [✔️] .sql file generation (PostgreSQL, SQLite)
|
||||
- [✔️] .dbml: Database Markup Language (DBML) for textual schema representation.
|
||||
- [✔️] Prisma schema support (PSL format) .prisma
|
||||
- [ ] Sequelize (Typescript/Javascript) (Use templates, 💲 Someone can do this, not me)
|
||||
- [✔️] Drizzle ORM support .ts (TypeScript / JavaScript) (Mr. Edd wanted to move from Prisma to Drizzle. If you are bugs, you are welcome to do pull requests or issues)
|
||||
- [☠️] Entity Framework (.NET) model .edmx (Fuck no, EDMX files were bloated, verbose XML nightmares—hard to merge, error-prone, and a pain in teams. Microsoft wisely ditched them in EF Core for code-first. Classic overkill from old MS era.)
|
||||
- [✔️] TypeORM support
|
||||
- [] .hbm.xml / schema.xml: Hibernate/Propel mappings (Java/PHP) (💲 Someone can do this, not me)
|
||||
- [ ] Django models.py (Python classes), Sequelize migrations (JS) (💲 Someone can do this, not me)
|
||||
- [] .avsc: Avro schema (JSON format for data serialization) (💲 Someone can do this, not me)
|
||||
- [ ] .hbm.xml / schema.xml: Hibernate/Propel mappings (Java/PHP) (Use templates, 💲 Someone can do this, not me)
|
||||
- [ ] Django models.py (Python classes), Sequelize migrations (JS) (Use templates, 💲 Someone can do this, not me)
|
||||
- [ ] SQLAlchemy, Python (Use templates, 💲 Someone can do this, not me)
|
||||
- [ ] .avsc: Avro schema (JSON format for data serialization) (Use templates, 💲 Someone can do this, not me)
|
||||
- [ ] Rails schema in db/schema.rb (Use templates, 💲 Someone can do this, not me)
|
||||
- [ ] Laravel migration (Use templates, 💲 Someone can do this, not me)
|
||||
- [ ] Hibernate / JPA (Use templates, 💲 Someone can do this, not me)
|
||||
- [✔️] GraphQL schema generation
|
||||
|
||||
## UI
|
||||
@@ -39,13 +44,19 @@
|
||||
|
||||
## Advanced Features
|
||||
|
||||
- [ ] Dry-run mode for validation
|
||||
- [x] Diff tool for comparing specifications
|
||||
- [ ] Migration script generation
|
||||
- [ ] Dry-run mode for validation (only `job run --dry-run`; not on convert/merge/split)
|
||||
- [✔️] Diff tool for comparing specifications
|
||||
- [✔️] Migration script generation (PostgreSQL diff, live database by default for direct output)
|
||||
- [ ] Custom type mapping configuration
|
||||
- [ ] Batch processing support
|
||||
- [ ] Batch processing support (partial: job files run named workflows via `relspec job run`)
|
||||
- [ ] Watch mode for auto-regeneration
|
||||
|
||||
## Distribution
|
||||
|
||||
- [ ] NSIS Windows installer
|
||||
- [ ] Check https://git.warky.dev/wdevs/relspecgo/releases for new releases and prompt to update
|
||||
- [ ] CI pipeline to build and publish the Windows installer
|
||||
|
||||
## Future Considerations
|
||||
|
||||
- [ ] Web UI for visual editing
|
||||
|
||||
@@ -203,7 +203,8 @@ func runAssetsExecute(cmd *cobra.Command, args []string) error {
|
||||
if len(failures) > 0 {
|
||||
fmt.Fprintf(os.Stderr, "Failed: %d\n", len(failures))
|
||||
fmt.Fprintf(os.Stderr, "\n⚠ Failed Entries Summary (%d failed):\n", len(failures))
|
||||
for i, f := range failures {
|
||||
for i := range failures {
|
||||
f := &failures[i]
|
||||
fmt.Fprintf(os.Stderr, " %d. %s (Priority=%d, Sequence=%d)\n Error: %v\n",
|
||||
i+1, filepath.Base(f.item.Entry.File), f.item.Priority, f.item.Sequence, f.err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var (
|
||||
batchSourceType string
|
||||
batchInputs []string
|
||||
batchTargetType string
|
||||
batchTargetDir string
|
||||
batchPackageName string
|
||||
batchSchemaFilter string
|
||||
batchFlattenSchema bool
|
||||
batchNullableTypes string
|
||||
batchNullableArrays string
|
||||
batchContinueOnError bool
|
||||
batchKeepGoing bool
|
||||
batchDryRun bool
|
||||
)
|
||||
|
||||
var batchCmd = &cobra.Command{
|
||||
Use: "batch",
|
||||
Short: "Convert many input files to a target format in one run",
|
||||
Long: `Convert each input file independently to the target format.
|
||||
|
||||
Unlike 'convert --from-list', which merges all inputs into one output, batch
|
||||
mode writes one output per input into --to-dir. The output is named after the
|
||||
input file (without its extension). Directory-style targets (gorm, bun,
|
||||
drizzle) get a sub-directory per input.
|
||||
|
||||
Inputs are given with --input, which accepts file paths and glob patterns and
|
||||
may be repeated or comma-separated. Inputs are processed in sorted order and
|
||||
duplicates are removed. The command exits non-zero if any input fails.
|
||||
|
||||
For named, multi-step workflows use 'relspec job run' instead.
|
||||
|
||||
Examples:
|
||||
# Convert every DBML file in a directory to JSON
|
||||
relspec batch --from dbml --input "schemas/*.dbml" --to json --to-dir out/
|
||||
|
||||
# Convert specific files to GORM models, one package directory per input
|
||||
relspec batch --from json --input a.json,b.json \
|
||||
--to gorm --to-dir models/ --package models
|
||||
|
||||
# Validate everything first, writing nothing
|
||||
relspec batch --from yaml --input "specs/*.yaml" --to pgsql --to-dir sql/ --dry-run
|
||||
|
||||
# Report all failures instead of stopping at the first
|
||||
relspec batch --from json --input "*.json" --to yaml --to-dir out/ --keep-going`,
|
||||
RunE: runBatch,
|
||||
}
|
||||
|
||||
func init() {
|
||||
batchCmd.Flags().StringVar(&batchSourceType, "from", "", "Source format for every input (dbml, dctx, drawdb, graphql, json, yaml, gorm, bun, drizzle, prisma, typeorm, sqlite)")
|
||||
batchCmd.Flags().StringSliceVar(&batchInputs, "input", nil, "Input file path or glob pattern (repeatable, comma-separated)")
|
||||
batchCmd.Flags().StringVar(&batchTargetType, "to", "", "Target format")
|
||||
batchCmd.Flags().StringVar(&batchTargetDir, "to-dir", "", "Output directory; one output per input is written here")
|
||||
batchCmd.Flags().StringVar(&batchPackageName, "package", "", "Package name (for code generation formats like gorm/bun)")
|
||||
batchCmd.Flags().StringVar(&batchSchemaFilter, "schema", "", "Filter to a specific schema by name")
|
||||
batchCmd.Flags().BoolVar(&batchFlattenSchema, "flatten-schema", false, "Flatten schema.table names to schema_table")
|
||||
batchCmd.Flags().StringVar(&batchNullableTypes, "types", "", "Nullable type package for code-gen writers (bun/gorm)")
|
||||
batchCmd.Flags().StringVar(&batchNullableArrays, "array-nullable", "", "Nullable array representation for the Bun writer")
|
||||
batchCmd.Flags().BoolVar(&batchContinueOnError, "continue-on-error", false, "Prepend \\set ON_ERROR_STOP off to generated SQL (pgsql output only)")
|
||||
batchCmd.Flags().BoolVar(&batchContinueOnError, "coe", false, "Alias for --continue-on-error")
|
||||
batchCmd.Flags().BoolVar(&batchKeepGoing, "keep-going", false, "Process remaining inputs after a failure; still exits non-zero")
|
||||
batchCmd.Flags().BoolVar(&batchDryRun, "dry-run", false, "Read and validate every input and print the plan without writing any output")
|
||||
|
||||
for _, f := range []string{"from", "input", "to", "to-dir"} {
|
||||
if err := batchCmd.MarkFlagRequired(f); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error marking %s flag as required: %v\n", f, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// batchDirTargets are writers that emit a directory of files rather than one file.
|
||||
var batchDirTargets = map[string]bool{"gorm": true, "bun": true, "drizzle": true}
|
||||
|
||||
// batchExtensions maps single-file target formats to their output extension.
|
||||
var batchExtensions = map[string]string{
|
||||
"dbml": ".dbml", "dctx": ".dctx", "drawdb": ".ddb", "json": ".json",
|
||||
"yaml": ".yaml", "yml": ".yaml", "pgsql": ".sql", "postgres": ".sql",
|
||||
"postgresql": ".sql", "sql": ".sql", "mssql": ".sql", "sqlserver": ".sql",
|
||||
"mssql2016": ".sql", "mssql2017": ".sql", "mssql2019": ".sql", "mssql2022": ".sql",
|
||||
"sqlite": ".sql", "sqlite3": ".sql", "prisma": ".prisma", "typeorm": ".ts",
|
||||
"graphql": ".graphql", "gql": ".graphql",
|
||||
}
|
||||
|
||||
// expandBatchInputs resolves paths and glob patterns into a sorted,
|
||||
// de-duplicated file list. A pattern that matches nothing is an error.
|
||||
func expandBatchInputs(patterns []string) ([]string, error) {
|
||||
seen := map[string]bool{}
|
||||
var files []string
|
||||
for _, p := range patterns {
|
||||
p = strings.TrimSpace(p)
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
matches, err := filepath.Glob(p)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid pattern %q: %w", p, err)
|
||||
}
|
||||
if len(matches) == 0 {
|
||||
return nil, fmt.Errorf("no files match %q", p)
|
||||
}
|
||||
for _, m := range matches {
|
||||
if !seen[m] {
|
||||
seen[m] = true
|
||||
files = append(files, m)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(files) == 0 {
|
||||
return nil, fmt.Errorf("no input files given")
|
||||
}
|
||||
sort.Strings(files)
|
||||
return files, nil
|
||||
}
|
||||
|
||||
// batchOutputPaths returns the output path for each input. It errors when two
|
||||
// inputs would collide on the same output name.
|
||||
func batchOutputPaths(files []string, targetType, dir string) ([]string, error) {
|
||||
key := strings.ToLower(targetType)
|
||||
ext := ""
|
||||
if !batchDirTargets[key] {
|
||||
var ok bool
|
||||
if ext, ok = batchExtensions[key]; !ok {
|
||||
return nil, fmt.Errorf("unsupported target format: %s", targetType)
|
||||
}
|
||||
}
|
||||
outs := make([]string, len(files))
|
||||
owner := map[string]string{}
|
||||
for i, f := range files {
|
||||
stem := strings.TrimSuffix(filepath.Base(f), filepath.Ext(f))
|
||||
out := filepath.Join(dir, stem+ext)
|
||||
if prev, dup := owner[out]; dup {
|
||||
return nil, fmt.Errorf("inputs %s and %s would both write %s", prev, f, out)
|
||||
}
|
||||
owner[out] = f
|
||||
outs[i] = out
|
||||
}
|
||||
return outs, nil
|
||||
}
|
||||
|
||||
func runBatch(cmd *cobra.Command, args []string) error {
|
||||
files, err := expandBatchInputs(batchInputs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
outs, err := batchOutputPaths(files, batchTargetType, batchTargetDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "\n=== RelSpec Batch Converter ===\n")
|
||||
fmt.Fprintf(os.Stderr, "Started at: %s\n", getCurrentTimestamp())
|
||||
fmt.Fprintf(os.Stderr, "Inputs: %d file(s), %s -> %s\n\n", len(files), batchSourceType, batchTargetType)
|
||||
|
||||
out := outWriter(cmd)
|
||||
if batchDryRun {
|
||||
fmt.Fprintf(out, "RelSpec batch plan (dry run - nothing written):\n")
|
||||
}
|
||||
|
||||
var failed []string
|
||||
for i, f := range files {
|
||||
fmt.Fprintf(os.Stderr, "[%d/%d] %s -> %s\n", i+1, len(files), f, outs[i])
|
||||
if err := processBatchItem(cmd, f, outs[i]); err != nil {
|
||||
fmt.Fprintf(os.Stderr, " ✗ %v\n", err)
|
||||
failed = append(failed, fmt.Sprintf("%s: %v", f, err))
|
||||
if !batchKeepGoing {
|
||||
break
|
||||
}
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, " ✓ done\n")
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "\n=== Batch Complete: %d ok, %d failed ===\n", len(files)-len(failed), len(failed))
|
||||
if len(failed) > 0 {
|
||||
return fmt.Errorf("batch finished with %d failure(s):\n %s", len(failed), strings.Join(failed, "\n "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func processBatchItem(cmd *cobra.Command, in, outPath string) error {
|
||||
db, err := readDatabaseForConvert(batchSourceType, in, "")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read source: %w", err)
|
||||
}
|
||||
finalizeCommentedRefs(db, stderrWarn)
|
||||
|
||||
if batchDryRun {
|
||||
if err := validateWriteTarget(db, batchTargetType, batchPackageName, batchSchemaFilter, ""); err != nil {
|
||||
return fmt.Errorf("dry run validation failed: %w", err)
|
||||
}
|
||||
w := outWriter(cmd)
|
||||
fmt.Fprintf(w, " %s -> %s (database '%s')\n", in, outPath, db.Name)
|
||||
printDryRunPlan(w, db)
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(batchTargetDir, 0o755); err != nil {
|
||||
return fmt.Errorf("failed to create output directory: %w", err)
|
||||
}
|
||||
if err := writeDatabase(db, batchTargetType, outPath, batchPackageName, batchSchemaFilter, batchFlattenSchema, batchNullableTypes, batchNullableArrays, batchContinueOnError, ""); err != nil {
|
||||
return fmt.Errorf("failed to write target: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func saveBatchState(t *testing.T) {
|
||||
t.Helper()
|
||||
a, b, c, d, e, f, g := batchSourceType, batchInputs, batchTargetType, batchTargetDir, batchPackageName, batchKeepGoing, batchDryRun
|
||||
t.Cleanup(func() {
|
||||
batchSourceType, batchInputs, batchTargetType, batchTargetDir, batchPackageName, batchKeepGoing, batchDryRun = a, b, c, d, e, f, g
|
||||
})
|
||||
}
|
||||
|
||||
func TestRunBatch_ConvertsEachInput(t *testing.T) {
|
||||
saveBatchState(t)
|
||||
dir := t.TempDir()
|
||||
writeTestJSON(t, filepath.Join(dir, "a.json"), []string{"users"})
|
||||
writeTestJSON(t, filepath.Join(dir, "b.json"), []string{"posts"})
|
||||
outDir := filepath.Join(dir, "out")
|
||||
|
||||
batchSourceType, batchTargetType, batchTargetDir = "json", "yaml", outDir
|
||||
batchPackageName, batchKeepGoing, batchDryRun = "", false, false
|
||||
batchInputs = []string{filepath.Join(dir, "*.json")}
|
||||
|
||||
cmd, _ := newDryRunCmd()
|
||||
if err := runBatch(cmd, nil); err != nil {
|
||||
t.Fatalf("batch: %v", err)
|
||||
}
|
||||
for _, name := range []string{"a.yaml", "b.yaml"} {
|
||||
if _, err := os.Stat(filepath.Join(outDir, name)); err != nil {
|
||||
t.Errorf("expected %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunBatch_DryRunWritesNothing(t *testing.T) {
|
||||
saveBatchState(t)
|
||||
dir := t.TempDir()
|
||||
writeTestJSON(t, filepath.Join(dir, "a.json"), []string{"users"})
|
||||
outDir := filepath.Join(dir, "out")
|
||||
|
||||
batchSourceType, batchTargetType, batchTargetDir = "json", "yaml", outDir
|
||||
batchPackageName, batchKeepGoing, batchDryRun = "", false, true
|
||||
batchInputs = []string{filepath.Join(dir, "a.json")}
|
||||
|
||||
cmd, buf := newDryRunCmd()
|
||||
if err := runBatch(cmd, nil); err != nil {
|
||||
t.Fatalf("dry run: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(outDir); !os.IsNotExist(err) {
|
||||
t.Fatal("dry run must not create the output directory")
|
||||
}
|
||||
if !strings.Contains(buf.String(), "users") || !strings.Contains(buf.String(), "a.yaml") {
|
||||
t.Errorf("plan incomplete:\n%s", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunBatch_FailureHandling(t *testing.T) {
|
||||
saveBatchState(t)
|
||||
dir := t.TempDir()
|
||||
writeTestJSON(t, filepath.Join(dir, "a.json"), []string{"users"})
|
||||
if err := os.WriteFile(filepath.Join(dir, "b.json"), []byte("{not json"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeTestJSON(t, filepath.Join(dir, "c.json"), []string{"posts"})
|
||||
outDir := filepath.Join(dir, "out")
|
||||
|
||||
batchSourceType, batchTargetType, batchTargetDir = "json", "yaml", outDir
|
||||
batchPackageName, batchDryRun = "", false
|
||||
batchInputs = []string{filepath.Join(dir, "*.json")}
|
||||
cmd, _ := newDryRunCmd()
|
||||
|
||||
// Default: stop at first failure.
|
||||
batchKeepGoing = false
|
||||
err := runBatch(cmd, nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "b.json") {
|
||||
t.Fatalf("expected failure naming b.json, got %v", err)
|
||||
}
|
||||
if _, statErr := os.Stat(filepath.Join(outDir, "c.yaml")); !os.IsNotExist(statErr) {
|
||||
t.Error("c.json should not be processed without --keep-going")
|
||||
}
|
||||
|
||||
// --keep-going: remaining inputs are processed, exit still fails.
|
||||
batchKeepGoing = true
|
||||
if err := runBatch(cmd, nil); err == nil {
|
||||
t.Fatal("expected non-zero result with --keep-going")
|
||||
}
|
||||
if _, statErr := os.Stat(filepath.Join(outDir, "c.yaml")); statErr != nil {
|
||||
t.Errorf("c.yaml should be written with --keep-going: %v", statErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpandBatchInputs(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
for _, n := range []string{"b.json", "a.json"} {
|
||||
if err := os.WriteFile(filepath.Join(dir, n), []byte("{}"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
got, err := expandBatchInputs([]string{filepath.Join(dir, "*.json"), filepath.Join(dir, "a.json")})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 2 || filepath.Base(got[0]) != "a.json" || filepath.Base(got[1]) != "b.json" {
|
||||
t.Errorf("want sorted deduped [a b], got %v", got)
|
||||
}
|
||||
if _, err := expandBatchInputs([]string{filepath.Join(dir, "*.nope")}); err == nil {
|
||||
t.Error("unmatched pattern should error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchOutputPaths(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
files []string
|
||||
target string
|
||||
want []string
|
||||
wantErr string
|
||||
}{
|
||||
{"file target", []string{"x/a.dbml"}, "json", []string{"out/a.json"}, ""},
|
||||
{"dir target", []string{"x/a.json"}, "gorm", []string{"out/a"}, ""},
|
||||
{"collision", []string{"x/a.json", "y/a.json"}, "yaml", nil, "both write"},
|
||||
{"unsupported", []string{"a.json"}, "nope", nil, "unsupported target"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := batchOutputPaths(tt.files, tt.target, "out")
|
||||
if tt.wantErr != "" {
|
||||
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("want error %q, got %v", tt.wantErr, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil || len(got) != len(tt.want) || got[0] != filepath.FromSlash(tt.want[0]) {
|
||||
t.Fatalf("got %v, %v; want %v", got, err, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+184
-19
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
stdjson "encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -21,11 +22,13 @@ import (
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers/graphql"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers/json"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers/mssql"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers/mysql"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers/pgsql"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers/prisma"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers/sqlite"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers/typeorm"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers/yaml"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/transform"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/writers"
|
||||
wbun "git.warky.dev/wdevs/relspecgo/pkg/writers/bun"
|
||||
wdbml "git.warky.dev/wdevs/relspecgo/pkg/writers/dbml"
|
||||
@@ -36,6 +39,7 @@ import (
|
||||
wgraphql "git.warky.dev/wdevs/relspecgo/pkg/writers/graphql"
|
||||
wjson "git.warky.dev/wdevs/relspecgo/pkg/writers/json"
|
||||
wmssql "git.warky.dev/wdevs/relspecgo/pkg/writers/mssql"
|
||||
wmysql "git.warky.dev/wdevs/relspecgo/pkg/writers/mysql"
|
||||
wpgsql "git.warky.dev/wdevs/relspecgo/pkg/writers/pgsql"
|
||||
wprisma "git.warky.dev/wdevs/relspecgo/pkg/writers/prisma"
|
||||
wsqlite "git.warky.dev/wdevs/relspecgo/pkg/writers/sqlite"
|
||||
@@ -57,6 +61,9 @@ var (
|
||||
convertNullableArrays string
|
||||
convertContinueOnError bool
|
||||
convertExtraFields string
|
||||
convertDryRun bool
|
||||
convertWatch bool
|
||||
convertWatchInterval time.Duration
|
||||
)
|
||||
|
||||
var convertCmd = &cobra.Command{
|
||||
@@ -165,17 +172,21 @@ Examples:
|
||||
|
||||
# Convert SQLite to PostgreSQL SQL
|
||||
relspec convert --from sqlite --from-path database.db \
|
||||
--to pgsql --to-path schema.sql`,
|
||||
--to pgsql --to-path schema.sql
|
||||
|
||||
# Regenerate GORM models every time the DBML file changes
|
||||
relspec convert --from dbml --from-path schema.dbml \
|
||||
--to gorm --to-path models/ --package models --watch`,
|
||||
RunE: runConvert,
|
||||
}
|
||||
|
||||
func init() {
|
||||
convertCmd.Flags().StringVar(&convertSourceType, "from", "", "Source format (dbml, dctx, drawdb, graphql, json, yaml, gorm, bun, drizzle, prisma, typeorm, pgsql, sqlite)")
|
||||
convertCmd.Flags().StringVar(&convertSourceType, "from", "", "Source format (dbml, dctx, drawdb, graphql, json, yaml, gorm, bun, drizzle, prisma, typeorm, pgsql, mssql, mysql, sqlite)")
|
||||
convertCmd.Flags().StringVar(&convertSourcePath, "from-path", "", "Source file path (for file-based formats)")
|
||||
convertCmd.Flags().StringVar(&convertSourceConn, "from-conn", "", "Source connection string (for pgsql) or file path (for sqlite)")
|
||||
convertCmd.Flags().StringVar(&convertSourceConn, "from-conn", "", "Source connection string (pgsql, mssql, mysql) or file path (sqlite)")
|
||||
convertCmd.Flags().StringSliceVar(&convertFromList, "from-list", nil, "Comma-separated list of source file paths to read and merge (mutually exclusive with --from-path)")
|
||||
|
||||
convertCmd.Flags().StringVar(&convertTargetType, "to", "", "Target format (dbml, dctx, drawdb, graphql, json, yaml, gorm, bun, drizzle, prisma, typeorm, pgsql)")
|
||||
convertCmd.Flags().StringVar(&convertTargetType, "to", "", "Target format (dbml, dctx, drawdb, graphql, json, yaml, gorm, bun, drizzle, prisma, typeorm, pgsql, mssql, mysql, sqlite)")
|
||||
convertCmd.Flags().StringVar(&convertTargetPath, "to-path", "", "Target output path (file or directory)")
|
||||
convertCmd.Flags().StringVar(&convertPackageName, "package", "", "Package name (for code generation formats like gorm/bun)")
|
||||
convertCmd.Flags().StringVar(&convertSchemaFilter, "schema", "", "Filter to a specific schema by name (required for formats like dctx that only support single schemas)")
|
||||
@@ -183,8 +194,14 @@ func init() {
|
||||
convertCmd.Flags().StringVar(&convertNullableTypes, "types", "", "Nullable type package for code-gen writers (bun/gorm): 'baselib' (default, Go pointer types), 'stdlib' (database/sql), or 'sqltypes'")
|
||||
convertCmd.Flags().StringVar(&convertNullableArrays, "array-nullable", "", "Nullable PostgreSQL array representation for the Bun writer in stdlib/baselib --types mode: 'slice' (default, plain slice) or 'pointer_slice' (*[]T, distinguishes NULL from '{}')")
|
||||
convertCmd.Flags().BoolVar(&convertContinueOnError, "continue-on-error", false, "Prepend \\set ON_ERROR_STOP off to generated SQL so psql continues past errors (pgsql output only)")
|
||||
convertCmd.Flags().BoolVar(&convertContinueOnError, "coe", false, "Alias for --continue-on-error")
|
||||
convertCmd.Flags().StringVar(&convertExtraFields, "extra-fields", "", "Path to JSON file containing extra Bun model fields to inject (bun output only); fields support target_table, name, type, bun_tag, json_tag, comment")
|
||||
|
||||
convertCmd.Flags().BoolVar(&convertDryRun, "dry-run", false, "Read and validate the input and print the plan without writing any output")
|
||||
|
||||
convertCmd.Flags().BoolVar(&convertWatch, "watch", false, "Watch the source files (--from-path or --from-list) and regenerate the output whenever they change")
|
||||
convertCmd.Flags().DurationVar(&convertWatchInterval, "watch-interval", 500*time.Millisecond, "Polling interval used by --watch")
|
||||
|
||||
err := convertCmd.MarkFlagRequired("from")
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error marking from flag as required: %v\n", err)
|
||||
@@ -200,6 +217,13 @@ func init() {
|
||||
}
|
||||
|
||||
func runConvert(cmd *cobra.Command, args []string) error {
|
||||
if convertWatch {
|
||||
return runConvertWatch(cmd.Context(), os.Stderr, func() error { return runConvertOnce(cmd) })
|
||||
}
|
||||
return runConvertOnce(cmd)
|
||||
}
|
||||
|
||||
func runConvertOnce(cmd *cobra.Command) error {
|
||||
fmt.Fprintf(os.Stderr, "\n=== RelSpec Schema Converter ===\n")
|
||||
fmt.Fprintf(os.Stderr, "Started at: %s\n\n", getCurrentTimestamp())
|
||||
|
||||
@@ -229,6 +253,7 @@ func runConvert(cmd *cobra.Command, args []string) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read source: %w", err)
|
||||
}
|
||||
finalizeCommentedRefs(db, stderrWarn)
|
||||
|
||||
fmt.Fprintf(os.Stderr, " ✓ Successfully read database '%s'\n", db.Name)
|
||||
fmt.Fprintf(os.Stderr, " Found: %d schema(s)\n", len(db.Schemas))
|
||||
@@ -239,6 +264,22 @@ func runConvert(cmd *cobra.Command, args []string) error {
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, " Found: %d table(s)\n\n", totalTables)
|
||||
|
||||
if convertDryRun {
|
||||
if err := validateWriteTarget(db, convertTargetType, convertPackageName, convertSchemaFilter, convertExtraFields); err != nil {
|
||||
return fmt.Errorf("dry run validation failed: %w", err)
|
||||
}
|
||||
out := outWriter(cmd)
|
||||
fmt.Fprintf(out, "RelSpec convert plan (dry run - nothing written):\n")
|
||||
fmt.Fprintf(out, " Input: %s database '%s'\n", convertSourceType, db.Name)
|
||||
fmt.Fprintf(out, " Output: %s -> %s\n", convertTargetType, convertTargetPath)
|
||||
if convertSchemaFilter != "" {
|
||||
fmt.Fprintf(out, " Schema filter: %s\n", convertSchemaFilter)
|
||||
}
|
||||
printDryRunPlan(out, db)
|
||||
fmt.Fprintf(os.Stderr, "=== Dry run complete: no output written ===\n\n")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Write to target format
|
||||
fmt.Fprintf(os.Stderr, "[2/2] Writing to target format...\n")
|
||||
fmt.Fprintf(os.Stderr, " Format: %s\n", convertTargetType)
|
||||
@@ -261,6 +302,18 @@ func runConvert(cmd *cobra.Command, args []string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// finalizeCommentedRefs resolves DBML `// Ref:` comments against the fully
|
||||
// loaded model and warns about refs whose target is not loaded.
|
||||
func finalizeCommentedRefs(db *models.Database, warn func(string)) {
|
||||
for _, w := range dbml.ResolveCommentedRefs(db, true) {
|
||||
warn(w)
|
||||
}
|
||||
}
|
||||
|
||||
func stderrWarn(msg string) {
|
||||
fmt.Fprintf(os.Stderr, " ⚠ %s\n", msg)
|
||||
}
|
||||
|
||||
func readDatabaseListForConvert(dbType string, files []string) (*models.Database, error) {
|
||||
if len(files) == 0 {
|
||||
return nil, fmt.Errorf("file list is empty")
|
||||
@@ -367,6 +420,12 @@ func readDatabaseForConvert(dbType, filePath, connString string) (*models.Databa
|
||||
}
|
||||
reader = mssql.NewReader(newReaderOptions("", connString))
|
||||
|
||||
case "mysql", "mariadb":
|
||||
if connString == "" {
|
||||
return nil, fmt.Errorf("connection string is required for MySQL format")
|
||||
}
|
||||
reader = mysql.NewReader(newReaderOptions("", connString))
|
||||
|
||||
case "sqlite", "sqlite3":
|
||||
// SQLite can use either file path or connection string
|
||||
dbPath := filePath
|
||||
@@ -390,28 +449,29 @@ func readDatabaseForConvert(dbType, filePath, connString string) (*models.Databa
|
||||
return db, nil
|
||||
}
|
||||
|
||||
func writeDatabase(db *models.Database, dbType, outputPath, packageName, schemaFilter string, flattenSchema bool, nullableTypes, nullableArrays string, continueOnError bool, extraFields string) error {
|
||||
func writeDatabase(db *models.Database, dbType, outputPath, packageName, schemaFilter string, flattenSchema bool, nullableTypes, nullableArrays string, continueOnError bool, extraFields string, strict ...bool) error {
|
||||
if lowercaseIdentifiers {
|
||||
var err error
|
||||
schemaFilter = strings.ToLower(schemaFilter)
|
||||
db, err = transform.LowercaseIdentifiers(db)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
var writer writers.Writer
|
||||
|
||||
writerOpts := newWriterOptions(outputPath, packageName, flattenSchema, nullableTypes, nullableArrays, continueOnError)
|
||||
if len(strict) > 0 {
|
||||
writerOpts.StrictConversion = writerOpts.StrictConversion || strict[0]
|
||||
}
|
||||
if extraFields != "" {
|
||||
if !strings.EqualFold(dbType, "bun") {
|
||||
return fmt.Errorf("--extra-fields is only supported for Bun output")
|
||||
}
|
||||
extraFieldsJSON, err := os.ReadFile(extraFields)
|
||||
extraFieldsJSON, err := loadExtraFields(dbType, extraFields)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read --extra-fields file %q: %w", extraFields, err)
|
||||
}
|
||||
|
||||
var parsed []wbun.ExtraFieldConfig
|
||||
if err := stdjson.Unmarshal(extraFieldsJSON, &parsed); err != nil {
|
||||
return fmt.Errorf("invalid --extra-fields JSON in %q: %w", extraFields, err)
|
||||
}
|
||||
if len(parsed) == 0 {
|
||||
return fmt.Errorf("--extra-fields must contain at least one field")
|
||||
return err
|
||||
}
|
||||
writerOpts.Metadata = map[string]interface{}{
|
||||
"extra_fields": string(extraFieldsJSON),
|
||||
"extra_fields": extraFieldsJSON,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -452,6 +512,9 @@ func writeDatabase(db *models.Database, dbType, outputPath, packageName, schemaF
|
||||
case "mssql", "sqlserver", "mssql2016", "mssql2017", "mssql2019", "mssql2022":
|
||||
writer = wmssql.NewWriter(writerOpts)
|
||||
|
||||
case "mysql", "mariadb":
|
||||
writer = wmysql.NewWriter(writerOpts)
|
||||
|
||||
case "sqlite", "sqlite3":
|
||||
writer = wsqlite.NewWriter(writerOpts)
|
||||
|
||||
@@ -516,6 +579,108 @@ func writeDatabase(db *models.Database, dbType, outputPath, packageName, schemaF
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadExtraFields reads and validates the --extra-fields JSON file, returning
|
||||
// its raw content.
|
||||
func loadExtraFields(dbType, path string) (string, error) {
|
||||
if !strings.EqualFold(dbType, "bun") {
|
||||
return "", fmt.Errorf("--extra-fields is only supported for Bun output")
|
||||
}
|
||||
extraFieldsJSON, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read --extra-fields file %q: %w", path, err)
|
||||
}
|
||||
|
||||
var parsed []wbun.ExtraFieldConfig
|
||||
if err := stdjson.Unmarshal(extraFieldsJSON, &parsed); err != nil {
|
||||
return "", fmt.Errorf("invalid --extra-fields JSON in %q: %w", path, err)
|
||||
}
|
||||
if len(parsed) == 0 {
|
||||
return "", fmt.Errorf("--extra-fields must contain at least one field")
|
||||
}
|
||||
return string(extraFieldsJSON), nil
|
||||
}
|
||||
|
||||
// validateWriteTarget performs the checks writeDatabase makes before writing,
|
||||
// without constructing a writer or touching the output path. Used by --dry-run.
|
||||
func validateWriteTarget(db *models.Database, dbType, packageName, schemaFilter, extraFields string) error {
|
||||
if extraFields != "" {
|
||||
if _, err := loadExtraFields(dbType, extraFields); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
switch strings.ToLower(dbType) {
|
||||
case "dbml", "dctx", "drawdb", "json", "yaml", "yml", "drizzle",
|
||||
"pgsql", "postgres", "postgresql", "sql",
|
||||
"mssql", "sqlserver", "mssql2016", "mssql2017", "mssql2019", "mssql2022",
|
||||
"sqlite", "sqlite3", "prisma", "typeorm", "graphql", "gql":
|
||||
case "mysql", "mariadb":
|
||||
selected := db
|
||||
if schemaFilter != "" {
|
||||
selected = &models.Database{}
|
||||
for _, s := range db.Schemas {
|
||||
if s.Name == schemaFilter {
|
||||
selected.Schemas = append(selected.Schemas, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
if _, err := wmysql.NewWriter(newWriterOptions("", packageName, false, "", "", false)).Statements(selected); err != nil {
|
||||
return err
|
||||
}
|
||||
case "gorm", "bun":
|
||||
if packageName == "" {
|
||||
return fmt.Errorf("package name is required for %s format (use --package flag)", dbType)
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("unsupported target format: %s", dbType)
|
||||
}
|
||||
|
||||
if schemaFilter != "" {
|
||||
for _, schema := range db.Schemas {
|
||||
if schema.Name == schemaFilter {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("schema '%s' not found in database. Available schemas: %v",
|
||||
schemaFilter, getSchemaNames(db))
|
||||
}
|
||||
|
||||
if strings.EqualFold(dbType, "dctx") {
|
||||
if len(db.Schemas) == 0 {
|
||||
return fmt.Errorf("no schemas found in database")
|
||||
}
|
||||
if len(db.Schemas) > 1 {
|
||||
return fmt.Errorf("multiple schemas found, please specify which schema to export using --schema flag. Available schemas: %v",
|
||||
getSchemaNames(db))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// outWriter returns the command's stdout, falling back to os.Stdout when the
|
||||
// command is nil (tests call the run functions directly).
|
||||
func outWriter(cmd *cobra.Command) io.Writer {
|
||||
if cmd == nil {
|
||||
return os.Stdout
|
||||
}
|
||||
return cmd.OutOrStdout()
|
||||
}
|
||||
|
||||
// printDryRunPlan prints the schemas and tables that would be written.
|
||||
func printDryRunPlan(out io.Writer, db *models.Database) {
|
||||
for _, schema := range db.Schemas {
|
||||
names := make([]string, 0, len(schema.Tables))
|
||||
for _, t := range schema.Tables {
|
||||
names = append(names, t.Name)
|
||||
}
|
||||
fmt.Fprintf(out, " schema %q: %d table(s)", schema.Name, len(schema.Tables))
|
||||
if len(names) > 0 {
|
||||
fmt.Fprintf(out, " [%s]", strings.Join(names, ", "))
|
||||
}
|
||||
fmt.Fprintln(out)
|
||||
}
|
||||
}
|
||||
|
||||
// getSchemaNames returns a slice of schema names from a database
|
||||
func getSchemaNames(db *models.Database) []string {
|
||||
names := make([]string, len(db.Schemas))
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||
)
|
||||
|
||||
const fixturesDir = "../../tests/assets"
|
||||
|
||||
// readableFormats maps each file-based reader format to an existing fixture.
|
||||
var readableFormats = []struct {
|
||||
format string
|
||||
path string
|
||||
}{
|
||||
{"dbml", "dbml/simple.dbml"},
|
||||
{"json", "json/database.json"},
|
||||
{"yaml", "yaml/database.yaml"},
|
||||
{"yml", "yaml/database.yaml"},
|
||||
{"drawdb", "drawdb/simple.json"},
|
||||
{"dctx", "dctx/p1.dctx"},
|
||||
{"graphql", "graphql/simple.graphql"},
|
||||
{"gql", "graphql/simple.graphql"},
|
||||
{"prisma", "prisma/example.prisma"},
|
||||
{"typeorm", "typeorm/example.ts"},
|
||||
{"drizzle", "drizzle/schema.ts"},
|
||||
{"gorm", "gorm/simple.go"},
|
||||
{"bun", "bun/simple.go"},
|
||||
}
|
||||
|
||||
func TestReadDatabaseForConvert_FileFormats(t *testing.T) {
|
||||
for _, tt := range readableFormats {
|
||||
t.Run(tt.format, func(t *testing.T) {
|
||||
db, err := readDatabaseForConvert(tt.format, filepath.Join(fixturesDir, tt.path), "")
|
||||
if err != nil {
|
||||
t.Fatalf("read: %v", err)
|
||||
}
|
||||
if db == nil || len(db.Schemas) == 0 {
|
||||
t.Fatalf("no schemas read: %+v", db)
|
||||
}
|
||||
// Uppercase format names are accepted.
|
||||
if _, err := readDatabaseForConvert(strings.ToUpper(tt.format), filepath.Join(fixturesDir, tt.path), ""); err != nil {
|
||||
t.Errorf("uppercase format: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadDatabaseForConvert_Errors(t *testing.T) {
|
||||
filePathFormats := []string{"dbml", "dctx", "drawdb", "json", "yaml", "gorm", "bun", "drizzle", "prisma", "typeorm", "graphql"}
|
||||
for _, f := range filePathFormats {
|
||||
t.Run("missing path "+f, func(t *testing.T) {
|
||||
_, err := readDatabaseForConvert(f, "", "")
|
||||
if err == nil || !strings.Contains(err.Error(), "file path is required") {
|
||||
t.Errorf("got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
connFormats := []string{"pgsql", "postgres", "postgresql", "mssql", "sqlserver", "mysql", "mariadb"}
|
||||
for _, f := range connFormats {
|
||||
t.Run("missing conn "+f, func(t *testing.T) {
|
||||
_, err := readDatabaseForConvert(f, "", "")
|
||||
if err == nil || !strings.Contains(err.Error(), "connection string is required") {
|
||||
t.Errorf("got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
if _, err := readDatabaseForConvert("sqlite", "", ""); err == nil || !strings.Contains(err.Error(), "required for SQLite") {
|
||||
t.Errorf("sqlite: %v", err)
|
||||
}
|
||||
if _, err := readDatabaseForConvert("nope", "x", ""); err == nil || !strings.Contains(err.Error(), "unsupported source format") {
|
||||
t.Errorf("unsupported: %v", err)
|
||||
}
|
||||
if _, err := readDatabaseForConvert("dbml", filepath.Join(t.TempDir(), "missing.dbml"), ""); err == nil || !strings.Contains(err.Error(), "failed to read database") {
|
||||
t.Errorf("missing file: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadDatabase_DiffReader(t *testing.T) {
|
||||
for _, f := range []string{"dbml", "json", "yaml", "drawdb", "dctx"} {
|
||||
for _, tt := range readableFormats {
|
||||
if tt.format != f {
|
||||
continue
|
||||
}
|
||||
t.Run(f, func(t *testing.T) {
|
||||
db, err := readDatabase(f, filepath.Join(fixturesDir, tt.path), "", "source")
|
||||
if err != nil || db == nil || len(db.Schemas) == 0 {
|
||||
t.Fatalf("read: %v %+v", err, db)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
for _, f := range []string{"dbml", "dctx", "drawdb", "json", "yaml", "sqldir"} {
|
||||
if _, err := readDatabase(f, "", "", "src"); err == nil || !strings.Contains(err.Error(), "src: file path is required") {
|
||||
t.Errorf("%s missing path: %v", f, err)
|
||||
}
|
||||
}
|
||||
if _, err := readDatabase("pgsql", "", "", "src"); err == nil || !strings.Contains(err.Error(), "connection string is required") {
|
||||
t.Errorf("pgsql: %v", err)
|
||||
}
|
||||
if _, err := readDatabase("sqlite", "", "", "src"); err == nil {
|
||||
t.Error("sqlite without path must fail")
|
||||
}
|
||||
if _, err := readDatabase("nope", "x", "", "src"); err == nil || !strings.Contains(err.Error(), "unsupported database format") {
|
||||
t.Errorf("unsupported: %v", err)
|
||||
}
|
||||
if _, err := readDatabase("json", filepath.Join(t.TempDir(), "missing.json"), "", "src"); err == nil || !strings.Contains(err.Error(), "src: failed to read database") {
|
||||
t.Errorf("missing file: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaskPassword(t *testing.T) {
|
||||
tests := []struct {
|
||||
in, want string
|
||||
}{
|
||||
{"", ""},
|
||||
{"postgres://user:secret@host:5432/db", "postgres://user:***@host:5432/db"},
|
||||
{"postgres://user@host:5432/db", "postgres://user@host:5432/db"},
|
||||
{"host=h user=u password=secret dbname=d", "host=h user=u password=*** dbname=d"},
|
||||
{"host=h user=u", "host=h user=u"},
|
||||
{"/tmp/file.db", "/tmp/file.db"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := maskPassword(tt.in); got != tt.want {
|
||||
t.Errorf("maskPassword(%q) = %q, want %q", tt.in, got, tt.want)
|
||||
}
|
||||
if got := maskPasswordInDiff(tt.in); got != tt.want {
|
||||
t.Errorf("maskPasswordInDiff(%q) = %q, want %q", tt.in, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetSchemaNames(t *testing.T) {
|
||||
db := models.InitDatabase("d")
|
||||
if got := getSchemaNames(db); len(got) != 0 {
|
||||
t.Errorf("empty: %v", got)
|
||||
}
|
||||
db.Schemas = []*models.Schema{{Name: "a"}, {Name: "b"}}
|
||||
if got := strings.Join(getSchemaNames(db), ","); got != "a,b" {
|
||||
t.Errorf("got %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadExtraFields(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
write := func(name, body string) string {
|
||||
p := filepath.Join(dir, name)
|
||||
if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
valid := write("valid.json", `[{"name":"extra"}]`)
|
||||
|
||||
if got, err := loadExtraFields("bun", valid); err != nil || !strings.Contains(got, "extra") {
|
||||
t.Errorf("valid: %q %v", got, err)
|
||||
}
|
||||
if _, err := loadExtraFields("BUN", valid); err != nil {
|
||||
t.Errorf("case-insensitive format: %v", err)
|
||||
}
|
||||
if _, err := loadExtraFields("gorm", valid); err == nil || !strings.Contains(err.Error(), "only supported for Bun") {
|
||||
t.Errorf("non-bun: %v", err)
|
||||
}
|
||||
if _, err := loadExtraFields("bun", filepath.Join(dir, "missing.json")); err == nil || !strings.Contains(err.Error(), "failed to read") {
|
||||
t.Errorf("missing: %v", err)
|
||||
}
|
||||
if _, err := loadExtraFields("bun", write("bad.json", `{not json`)); err == nil || !strings.Contains(err.Error(), "invalid --extra-fields JSON") {
|
||||
t.Errorf("bad json: %v", err)
|
||||
}
|
||||
if _, err := loadExtraFields("bun", write("empty.json", `[]`)); err == nil || !strings.Contains(err.Error(), "at least one field") {
|
||||
t.Errorf("empty: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func multiSchemaDB() *models.Database {
|
||||
db := models.InitDatabase("multi")
|
||||
for _, n := range []string{"a", "b"} {
|
||||
s := models.InitSchema(n)
|
||||
tbl := models.InitTable("t_"+n, n)
|
||||
c := models.InitColumn("id", tbl.Name, n)
|
||||
c.Type = "integer"
|
||||
c.IsPrimaryKey = true
|
||||
tbl.Columns["id"] = c
|
||||
s.Tables = append(s.Tables, tbl)
|
||||
db.Schemas = append(db.Schemas, s)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func TestValidateWriteTarget(t *testing.T) {
|
||||
db := multiSchemaDB()
|
||||
single := models.InitDatabase("single")
|
||||
single.Schemas = []*models.Schema{models.InitSchema("only")}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
db *models.Database
|
||||
dbType, pkg, schemaFilter, extraFields, wantErrSubstr string
|
||||
}{
|
||||
{"json ok", db, "json", "", "", "", ""},
|
||||
{"pgsql alias ok", db, "sql", "", "", "", ""},
|
||||
{"gorm needs package", db, "gorm", "", "", "", "package name is required"},
|
||||
{"bun needs package", db, "bun", "", "", "", "package name is required"},
|
||||
{"gorm with package", db, "gorm", "models", "", "", ""},
|
||||
{"unsupported", db, "nope", "", "", "", "unsupported target format"},
|
||||
{"schema filter found", db, "json", "", "a", "", ""},
|
||||
{"schema filter missing", db, "json", "", "zzz", "", "not found in database"},
|
||||
{"dctx multi schema", db, "dctx", "", "", "", "multiple schemas found"},
|
||||
{"dctx multi schema with filter", db, "dctx", "", "a", "", ""},
|
||||
{"dctx single schema", single, "dctx", "", "", "", ""},
|
||||
{"dctx no schemas", models.InitDatabase("e"), "dctx", "", "", "", "no schemas found"},
|
||||
{"extra fields non-bun", db, "json", "", "", "x.json", "only supported for Bun"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := validateWriteTarget(tt.db, tt.dbType, tt.pkg, tt.schemaFilter, tt.extraFields)
|
||||
if tt.wantErrSubstr == "" {
|
||||
if err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), tt.wantErrSubstr) {
|
||||
t.Errorf("got %v, want substring %q", err, tt.wantErrSubstr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteDatabase_Formats(t *testing.T) {
|
||||
db := multiSchemaDB()
|
||||
formats := []struct{ format, file string }{
|
||||
{"json", "out.json"},
|
||||
{"yaml", "out.yaml"},
|
||||
{"yml", "out.yml"},
|
||||
{"dbml", "out.dbml"},
|
||||
{"drawdb", "out.drawdb.json"},
|
||||
{"pgsql", "out.sql"},
|
||||
{"postgres", "out2.sql"},
|
||||
{"sql", "out3.sql"},
|
||||
{"mssql", "out_ms.sql"},
|
||||
{"mysql", "out_my.sql"},
|
||||
{"sqlite", "out_lite.sql"},
|
||||
{"graphql", "out.graphql"},
|
||||
{"gql", "out2.graphql"},
|
||||
{"prisma", "out.prisma"},
|
||||
{"typeorm", "out.ts"},
|
||||
{"drizzle", "out_drizzle.ts"},
|
||||
}
|
||||
for _, tt := range formats {
|
||||
t.Run(tt.format, func(t *testing.T) {
|
||||
out := filepath.Join(t.TempDir(), tt.file)
|
||||
if err := writeDatabase(db, tt.format, out, "", "", false, "", "", false, ""); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
info, err := os.Stat(out)
|
||||
if err != nil || info.Size() == 0 {
|
||||
t.Errorf("output missing or empty: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteDatabase_GoFormatsWriteIntoDir(t *testing.T) {
|
||||
db := multiSchemaDB()
|
||||
for _, f := range []string{"gorm", "bun"} {
|
||||
t.Run(f, func(t *testing.T) {
|
||||
out := filepath.Join(t.TempDir(), "models.go")
|
||||
if err := writeDatabase(db, f, out, "models", "", false, "", "", false, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(out); err != nil {
|
||||
t.Errorf("no output: %v", err)
|
||||
}
|
||||
if err := writeDatabase(db, f, out, "", "", false, "", "", false, ""); err == nil || !strings.Contains(err.Error(), "package name is required") {
|
||||
t.Errorf("missing package: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteDatabase_SchemaFilterAndDCTX(t *testing.T) {
|
||||
db := multiSchemaDB()
|
||||
out := filepath.Join(t.TempDir(), "o.json")
|
||||
|
||||
if err := writeDatabase(db, "json", out, "", "a", false, "", "", false, ""); err != nil {
|
||||
t.Errorf("schema filter: %v", err)
|
||||
}
|
||||
if err := writeDatabase(db, "json", out, "", "zzz", false, "", "", false, ""); err == nil || !strings.Contains(err.Error(), "not found in database") {
|
||||
t.Errorf("missing schema: %v", err)
|
||||
}
|
||||
if err := writeDatabase(db, "dctx", out, "", "", false, "", "", false, ""); err == nil || !strings.Contains(err.Error(), "multiple schemas found") {
|
||||
t.Errorf("dctx multi: %v", err)
|
||||
}
|
||||
single := models.InitDatabase("s")
|
||||
single.Schemas = []*models.Schema{db.Schemas[0]}
|
||||
if err := writeDatabase(single, "dctx", filepath.Join(t.TempDir(), "o.dctx"), "", "", false, "", "", false, ""); err != nil {
|
||||
t.Errorf("dctx single: %v", err)
|
||||
}
|
||||
if err := writeDatabase(models.InitDatabase("e"), "dctx", out, "", "", false, "", "", false, ""); err == nil || !strings.Contains(err.Error(), "no schemas found") {
|
||||
t.Errorf("dctx empty: %v", err)
|
||||
}
|
||||
if err := writeDatabase(db, "nope", out, "", "", false, "", "", false, ""); err == nil || !strings.Contains(err.Error(), "unsupported target format") {
|
||||
t.Errorf("unsupported: %v", err)
|
||||
}
|
||||
if err := writeDatabase(db, "json", out, "", "", false, "", "", false, filepath.Join(t.TempDir(), "x.json")); err == nil || !strings.Contains(err.Error(), "only supported for Bun") {
|
||||
t.Errorf("extra fields with json: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func newDryRunCmd() (*cobra.Command, *bytes.Buffer) {
|
||||
var buf bytes.Buffer
|
||||
cmd := &cobra.Command{}
|
||||
cmd.SetOut(&buf)
|
||||
return cmd, &buf
|
||||
}
|
||||
|
||||
func TestRunConvert_DryRunWritesNothing(t *testing.T) {
|
||||
defer func(a, b, c, d string, e bool) {
|
||||
convertSourceType, convertSourcePath, convertTargetType, convertTargetPath, convertDryRun = a, b, c, d, e
|
||||
}(convertSourceType, convertSourcePath, convertTargetType, convertTargetPath, convertDryRun)
|
||||
|
||||
dir := t.TempDir()
|
||||
in := filepath.Join(dir, "in.json")
|
||||
out := filepath.Join(dir, "out.json")
|
||||
writeTestJSON(t, in, []string{"users", "posts"})
|
||||
|
||||
convertSourceType, convertSourcePath = "json", in
|
||||
convertTargetType, convertTargetPath = "json", out
|
||||
convertDryRun = true
|
||||
|
||||
cmd, buf := newDryRunCmd()
|
||||
if err := runConvert(cmd, nil); err != nil {
|
||||
t.Fatalf("dry run: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(out); !os.IsNotExist(err) {
|
||||
t.Fatal("dry run must not create the output file")
|
||||
}
|
||||
for _, want := range []string{"dry run", "users", "posts", out} {
|
||||
if !strings.Contains(buf.String(), want) {
|
||||
t.Errorf("plan missing %q:\n%s", want, buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
// Normal behavior is unchanged.
|
||||
convertDryRun = false
|
||||
if err := runConvert(cmd, nil); err != nil {
|
||||
t.Fatalf("real run: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(out); err != nil {
|
||||
t.Fatalf("real run should write output: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunConvert_DryRunValidatesTarget(t *testing.T) {
|
||||
defer func(a, b, c, d string, e bool) {
|
||||
convertSourceType, convertSourcePath, convertTargetType, convertTargetPath, convertDryRun = a, b, c, d, e
|
||||
}(convertSourceType, convertSourcePath, convertTargetType, convertTargetPath, convertDryRun)
|
||||
|
||||
dir := t.TempDir()
|
||||
in := filepath.Join(dir, "in.json")
|
||||
out := filepath.Join(dir, "models")
|
||||
writeTestJSON(t, in, []string{"users"})
|
||||
|
||||
convertSourceType, convertSourcePath = "json", in
|
||||
convertDryRun = true
|
||||
|
||||
// gorm without --package must fail validation, as a real run would.
|
||||
convertTargetType, convertTargetPath = "gorm", out
|
||||
cmd, _ := newDryRunCmd()
|
||||
err := runConvert(cmd, nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "package name is required") {
|
||||
t.Fatalf("expected package validation error, got %v", err)
|
||||
}
|
||||
|
||||
convertTargetType = "nope"
|
||||
err = runConvert(cmd, nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "unsupported target format") {
|
||||
t.Fatalf("expected unsupported format error, got %v", err)
|
||||
}
|
||||
if _, statErr := os.Stat(out); !os.IsNotExist(statErr) {
|
||||
t.Fatal("dry run must not create the output path")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSplit_DryRunWritesNothing(t *testing.T) {
|
||||
defer func(a, b, c, d, e string, f bool) {
|
||||
splitSourceType, splitSourcePath, splitTargetType, splitTargetPath, splitTables, splitDryRun = a, b, c, d, e, f
|
||||
}(splitSourceType, splitSourcePath, splitTargetType, splitTargetPath, splitTables, splitDryRun)
|
||||
|
||||
dir := t.TempDir()
|
||||
in := filepath.Join(dir, "in.json")
|
||||
out := filepath.Join(dir, "subset.json")
|
||||
writeTestJSON(t, in, []string{"users", "posts", "comments"})
|
||||
|
||||
splitSourceType, splitSourcePath = "json", in
|
||||
splitTargetType, splitTargetPath = "json", out
|
||||
splitTables = "users,posts"
|
||||
splitDryRun = true
|
||||
|
||||
cmd, buf := newDryRunCmd()
|
||||
if err := runSplit(cmd, nil); err != nil {
|
||||
t.Fatalf("dry run: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(out); !os.IsNotExist(err) {
|
||||
t.Fatal("dry run must not create the output file")
|
||||
}
|
||||
got := buf.String()
|
||||
if !strings.Contains(got, "2 table(s)") || strings.Contains(got, "comments") {
|
||||
t.Errorf("plan should show only the 2 selected tables:\n%s", got)
|
||||
}
|
||||
|
||||
// A selection that matches nothing fails validation in dry-run too.
|
||||
splitTables = "does_not_exist"
|
||||
if err := runSplit(cmd, nil); err == nil {
|
||||
t.Fatal("expected error for empty selection")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunMerge_DryRunWritesNothing(t *testing.T) {
|
||||
saved := saveMergeState()
|
||||
defer restoreMergeState(saved)
|
||||
defer func(v bool) { mergeDryRun = v }(mergeDryRun)
|
||||
|
||||
dir := t.TempDir()
|
||||
target := filepath.Join(dir, "target.json")
|
||||
source := filepath.Join(dir, "source.json")
|
||||
out := filepath.Join(dir, "merged.json")
|
||||
writeTestJSON(t, target, []string{"users"})
|
||||
writeTestJSON(t, source, []string{"posts"})
|
||||
|
||||
mergeTargetType, mergeTargetPath, mergeTargetConn = "json", target, ""
|
||||
mergeSourceType, mergeSourcePath, mergeSourceConn = "json", source, ""
|
||||
mergeFromList = nil
|
||||
mergeOutputType, mergeOutputPath, mergeOutputConn = "json", out, ""
|
||||
mergeSkipTables, mergeReportPath = "", ""
|
||||
mergeDryRun = true
|
||||
|
||||
cmd, buf := newDryRunCmd()
|
||||
if err := runMerge(cmd, nil); err != nil {
|
||||
t.Fatalf("dry run: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(out); !os.IsNotExist(err) {
|
||||
t.Fatal("dry run must not create the output file")
|
||||
}
|
||||
for _, want := range []string{"dry run", "users", "posts", out} {
|
||||
if !strings.Contains(buf.String(), want) {
|
||||
t.Errorf("plan missing %q:\n%s", want, buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
mergeOutputType = "nope"
|
||||
if err := runMerge(cmd, nil); err == nil || !strings.Contains(err.Error(), "unsupported format") {
|
||||
t.Fatalf("expected unsupported output format error, got %v", err)
|
||||
}
|
||||
}
|
||||
+26
-4
@@ -19,6 +19,7 @@ import (
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers/sqldir"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/transform"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/writers"
|
||||
wpgsql "git.warky.dev/wdevs/relspecgo/pkg/writers/pgsql"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/writers/sqlexec"
|
||||
@@ -522,6 +523,12 @@ func runTemplJob(rj *resolvedJob, lg *jobLogger) error {
|
||||
return fmt.Errorf("schema not found: %s", schema)
|
||||
}
|
||||
}
|
||||
if lowercaseIdentifiers || rj.job.Options.LowercaseIdentifiers {
|
||||
db, err = transform.LowercaseIdentifiers(db)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
mode := rj.job.Mode
|
||||
if mode == "" {
|
||||
mode = "database"
|
||||
@@ -578,6 +585,7 @@ func runMergeJob(rj *resolvedJob, lg *jobLogger) error {
|
||||
lg.logf("merging: %s", inputLabel(ri))
|
||||
merge.MergeDatabases(base, db, opts)
|
||||
}
|
||||
finalizeCommentedRefs(base, func(w string) { lg.logf("warning: %s", w) })
|
||||
base.UpdateDate()
|
||||
return writeJobOutput(rj, base, lg)
|
||||
}
|
||||
@@ -776,6 +784,8 @@ func runScriptsExecJob(rj *resolvedJob, lg *jobLogger) error {
|
||||
lg.logf("executing %d script(s) against database env:%s", len(combined.Scripts), rj.outputConnEnv)
|
||||
|
||||
writer := sqlexec.NewWriter(&writers.WriterOptions{
|
||||
ContinueOnError: rj.job.Options.ContinueOnError,
|
||||
AtomicExecution: rj.job.Options.AtomicExecution,
|
||||
Metadata: map[string]any{
|
||||
"connection_string": rj.outputConn,
|
||||
"ignore_errors": rj.job.Options.ContinueOnError,
|
||||
@@ -790,7 +800,7 @@ func runScriptsExecJob(rj *resolvedJob, lg *jobLogger) error {
|
||||
success, _ := opts.Metadata["execution_success"].(int)
|
||||
failed, _ := opts.Metadata["execution_failed"].(int)
|
||||
lg.logf("executed %d script(s): %d succeeded, %d failed", total, success, failed)
|
||||
if failed > 0 && !rj.job.Options.ContinueOnError {
|
||||
if failed > 0 {
|
||||
return fmt.Errorf("%d script(s) failed", failed)
|
||||
}
|
||||
return nil
|
||||
@@ -814,6 +824,7 @@ func readJobInputs(rj *resolvedJob, lg *jobLogger) (*models.Database, error) {
|
||||
if base == nil {
|
||||
return nil, fmt.Errorf("no inputs produced a database")
|
||||
}
|
||||
finalizeCommentedRefs(base, func(w string) { lg.logf("warning: %s", w) })
|
||||
return base, nil
|
||||
}
|
||||
|
||||
@@ -833,7 +844,17 @@ func inputLabel(ri resolvedInput) string {
|
||||
|
||||
// writeJobOutput writes db to the job's output target (file or database).
|
||||
func writeJobOutput(rj *resolvedJob, db *models.Database, lg *jobLogger) error {
|
||||
if lowercaseIdentifiers || rj.job.Options.LowercaseIdentifiers {
|
||||
var err error
|
||||
db, err = transform.LowercaseIdentifiers(db)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
o := rj.job.Options
|
||||
if lowercaseIdentifiers || o.LowercaseIdentifiers {
|
||||
o.Schema = strings.ToLower(o.Schema)
|
||||
}
|
||||
format := strings.ToLower(rj.job.Output.Format)
|
||||
|
||||
if rj.outputConn != "" {
|
||||
@@ -841,8 +862,9 @@ func writeJobOutput(rj *resolvedJob, db *models.Database, lg *jobLogger) error {
|
||||
return fmt.Errorf("database output is only supported for pgsql (got %q)", rj.job.Output.Format)
|
||||
}
|
||||
lg.logf("writing output to database env:%s", rj.outputConnEnv)
|
||||
writerOpts := newWriterOptions("", o.Package, o.FlattenSchema, "", "", o.ContinueOnError)
|
||||
writerOpts.Metadata = map[string]interface{}{"connection_string": rj.outputConn}
|
||||
writerOpts := newWriterOptions("", o.Package, o.FlattenSchema, o.Types, o.ArrayNullable, o.ContinueOnError)
|
||||
writerOpts.AtomicExecution = o.AtomicExecution
|
||||
writerOpts.Metadata = map[string]interface{}{"connection_string": rj.outputConn, "full_ddl": o.FullDDL}
|
||||
return wpgsql.NewWriter(writerOpts).WriteDatabase(db)
|
||||
}
|
||||
|
||||
@@ -852,7 +874,7 @@ func writeJobOutput(rj *resolvedJob, db *models.Database, lg *jobLogger) error {
|
||||
lg.logf("writing output: %s (%s)", rj.outputPath, format)
|
||||
|
||||
write := func(target string) error {
|
||||
return writeDatabase(db, format, target, o.Package, o.Schema, o.FlattenSchema, "", "", o.ContinueOnError, "")
|
||||
return writeDatabase(db, format, target, o.Package, o.Schema, o.FlattenSchema, o.Types, o.ArrayNullable, o.ContinueOnError, "", o.StrictConversion)
|
||||
}
|
||||
// Single-file formats are written to a temp file and renamed into place so
|
||||
// a failure never leaves a partial or truncated output. Directory-emitting
|
||||
|
||||
@@ -2,11 +2,15 @@ package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/jobs"
|
||||
@@ -666,6 +670,7 @@ jobs:
|
||||
format: dbml
|
||||
template: templates/schema.tmpl
|
||||
output:
|
||||
format: text
|
||||
path: build/schema.txt
|
||||
overwrite: true
|
||||
`)
|
||||
@@ -682,3 +687,162 @@ jobs:
|
||||
t.Fatalf("templ output missing users table: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJobRun_BunSqlTypesOption(t *testing.T) {
|
||||
dir := jobFixture(t, `version: 1
|
||||
jobs:
|
||||
models:
|
||||
command: convert
|
||||
inputs:
|
||||
- path: schema/core.dbml
|
||||
format: dbml
|
||||
output:
|
||||
format: bun
|
||||
path: build/models
|
||||
options:
|
||||
package: models
|
||||
types: sqltypes
|
||||
`)
|
||||
set := mustLoadSet(t, filepath.Join(dir, "relspec.yml"))
|
||||
if err := executeJobPlan(set, "models", false, false, &bytes.Buffer{}); err != nil {
|
||||
t.Fatalf("execute Bun job: %v", err)
|
||||
}
|
||||
generated, err := os.ReadFile(filepath.Join(dir, "build", "models"))
|
||||
if err != nil {
|
||||
t.Fatalf("read Bun output: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(generated), "git.warky.dev/wdevs/relspecgo/pkg/sqltypes") {
|
||||
t.Fatalf("Bun output did not preserve options.types: %s", generated)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJobValidation_TemplTextFormat(t *testing.T) {
|
||||
validDir := jobFixture(t, `version: 1
|
||||
jobs:
|
||||
docs:
|
||||
command: templ
|
||||
inputs: [{path: schema/core.dbml, format: dbml}]
|
||||
template: schema.tmpl
|
||||
output: {format: text, path: build/docs.txt}
|
||||
`)
|
||||
valid := mustLoadSet(t, filepath.Join(validDir, "relspec.yml"))
|
||||
if err := valid.Validate(); err != nil {
|
||||
t.Fatalf("text output should be valid for templ: %v", err)
|
||||
}
|
||||
invalidDir := jobFixture(t, `version: 1
|
||||
jobs:
|
||||
docs:
|
||||
command: templ
|
||||
inputs: [{path: schema/core.dbml, format: dbml}]
|
||||
template: schema.tmpl
|
||||
output: {format: json, path: build/docs.txt}
|
||||
`)
|
||||
invalid, err := jobs.Load([]string{filepath.Join(invalidDir, "relspec.yml")})
|
||||
if err != nil {
|
||||
t.Fatalf("load invalid manifest: %v", err)
|
||||
}
|
||||
if err := invalid.Validate(); err == nil || !strings.Contains(err.Error(), "output.format: text") {
|
||||
t.Fatalf("expected templ format rejection, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLive_JobPostgresFailureStopsDependents(t *testing.T) {
|
||||
connString := os.Getenv("RELSPEC_TEST_PG_CONN")
|
||||
if connString == "" {
|
||||
t.Skip("RELSPEC_TEST_PG_CONN not set")
|
||||
}
|
||||
t.Setenv("RELSPEC_TEST_JOB_OUTPUT_CONN", connString)
|
||||
for _, continueOnError := range []string{"false", "true"} {
|
||||
t.Run("continue="+continueOnError, func(t *testing.T) {
|
||||
manifest := `version: 1
|
||||
jobs:
|
||||
apply:
|
||||
command: convert
|
||||
inputs:
|
||||
- path: schema/core.dbml
|
||||
format: dbml
|
||||
output:
|
||||
format: pgsql
|
||||
conn_env: RELSPEC_TEST_JOB_OUTPUT_CONN
|
||||
options:
|
||||
full_ddl: true
|
||||
continue_on_error: ` + continueOnError + `
|
||||
logfile: .relspec/apply.log
|
||||
dependent:
|
||||
command: convert
|
||||
depends_on: [apply]
|
||||
inputs:
|
||||
- path: schema/core.dbml
|
||||
format: dbml
|
||||
output:
|
||||
format: json
|
||||
path: build/dependent.json
|
||||
logfile: .relspec/dependent.log
|
||||
`
|
||||
dir := jobFixture(t, manifest)
|
||||
schema := fmt.Sprintf("job_audit_%d", time.Now().UnixNano())
|
||||
conn, err := pgx.Connect(context.Background(), connString)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = conn.Exec(context.Background(), "DROP SCHEMA IF EXISTS "+schema+" CASCADE")
|
||||
_ = conn.Close(context.Background())
|
||||
})
|
||||
// An invalid column type prevents table creation in this isolated schema.
|
||||
writeFile(t, filepath.Join(dir, "schema", "core.dbml"), "Table "+schema+".users {\n id no_such_type_audit_xyz\n}\n")
|
||||
set := mustLoadSet(t, filepath.Join(dir, "relspec.yml"))
|
||||
err = executeJobPlan(set, "dependent", false, false, &bytes.Buffer{})
|
||||
if err == nil || !strings.Contains(err.Error(), `job "apply" failed`) {
|
||||
t.Fatalf("expected database failure propagation, got %v", err)
|
||||
}
|
||||
for _, path := range []string{"build/dependent.json", ".relspec/dependent.log"} {
|
||||
if _, err := os.Stat(filepath.Join(dir, path)); !os.IsNotExist(err) {
|
||||
t.Fatalf("dependent must not start: path=%s err=%v", path, err)
|
||||
}
|
||||
}
|
||||
logData, err := os.ReadFile(filepath.Join(dir, ".relspec/apply.log"))
|
||||
if err != nil || !strings.Contains(string(logData), "FAILED") {
|
||||
t.Fatalf("failed application must be logged: %s err=%v", logData, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLive_JobScriptContinuationStillStopsDependents(t *testing.T) {
|
||||
connection := os.Getenv("RELSPEC_TEST_PG_CONN")
|
||||
if connection == "" {
|
||||
t.Skip("RELSPEC_TEST_PG_CONN not set")
|
||||
}
|
||||
t.Setenv("RELSPEC_TEST_EXEC_CONN", connection)
|
||||
dir := jobFixture(t, `version: 1
|
||||
jobs:
|
||||
migrate:
|
||||
command: scripts-exec
|
||||
script_dirs: [migrations]
|
||||
output:
|
||||
conn_env: RELSPEC_TEST_EXEC_CONN
|
||||
options:
|
||||
continue_on_error: true
|
||||
logfile: .relspec/migrate.log
|
||||
dependent:
|
||||
command: convert
|
||||
depends_on: [migrate]
|
||||
inputs:
|
||||
- path: schema/core.dbml
|
||||
format: dbml
|
||||
output:
|
||||
format: json
|
||||
path: build/dependent.json
|
||||
`)
|
||||
writeFile(t, filepath.Join(dir, "migrations", "1_001_bad.sql"), "SELECT 1 / 0;\n")
|
||||
writeFile(t, filepath.Join(dir, "migrations", "1_002_good.sql"), "SELECT 1;\n")
|
||||
set := mustLoadSet(t, filepath.Join(dir, "relspec.yml"))
|
||||
err := executeJobPlan(set, "dependent", false, false, &bytes.Buffer{})
|
||||
if err == nil || !strings.Contains(err.Error(), `job "migrate" failed`) {
|
||||
t.Fatalf("failed scripts must fail job: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "build", "dependent.json")); !os.IsNotExist(err) {
|
||||
t.Fatalf("dependent ran: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||
)
|
||||
|
||||
func TestLowercaseFlagAndFileOutput(t *testing.T) {
|
||||
prior := lowercaseIdentifiers
|
||||
t.Cleanup(func() { lowercaseIdentifiers = prior })
|
||||
for _, cmd := range []struct {
|
||||
name string
|
||||
flag bool
|
||||
}{{"convert", convertCmd.Flags().Lookup("lowercase") != nil}, {"batch", batchCmd.Flags().Lookup("lowercase") != nil}, {"merge", mergeCmd.Flags().Lookup("lowercase") != nil}, {"templ", templCmd.Flags().Lookup("lowercase") != nil}, {"job", jobCmd.PersistentFlags().Lookup("lowercase") != nil}} {
|
||||
if !cmd.flag {
|
||||
t.Fatalf("missing %s --lowercase", cmd.name)
|
||||
}
|
||||
}
|
||||
if err := convertCmd.Flags().Set("lowercase", "true"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path := filepath.Join(t.TempDir(), "schema.sql")
|
||||
db := models.InitDatabase("DB")
|
||||
s := models.InitSchema("App")
|
||||
table := models.InitTable("Users", "App")
|
||||
col := models.InitColumn("Name", "Users", "App")
|
||||
col.Type = "text"
|
||||
col.Default = "Keep CASE"
|
||||
table.Columns[col.Name] = col
|
||||
s.Tables = append(s.Tables, table)
|
||||
db.Schemas = append(db.Schemas, s)
|
||||
if err := writeDatabase(db, "pgsql", path, "", "", false, "", "", false, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(data), `"app"."users"`) || !strings.Contains(string(data), `"name" text DEFAULT 'Keep CASE'`) {
|
||||
t.Fatalf("%s", data)
|
||||
}
|
||||
if table.Name != "Users" {
|
||||
t.Fatal("input mutated")
|
||||
}
|
||||
}
|
||||
|
||||
func TestJobLowercaseOption(t *testing.T) {
|
||||
prior := lowercaseIdentifiers
|
||||
lowercaseIdentifiers = false
|
||||
t.Cleanup(func() { lowercaseIdentifiers = prior })
|
||||
dir := t.TempDir()
|
||||
writeTestJSON(t, filepath.Join(dir, "input.json"), []string{"MixedCase"})
|
||||
writeFile(t, filepath.Join(dir, "relspec.yml"), `version: 1
|
||||
jobs:
|
||||
lower:
|
||||
command: convert
|
||||
inputs:
|
||||
- format: json
|
||||
path: input.json
|
||||
output:
|
||||
format: json
|
||||
path: output.json
|
||||
options:
|
||||
lowercase_identifiers: true
|
||||
`)
|
||||
set := mustLoadSet(t, filepath.Join(dir, "relspec.yml"))
|
||||
if err := executeJobPlan(set, "lower", false, false, &bytes.Buffer{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(dir, "output.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(data), "mixedcase") || strings.Contains(string(data), "MixedCase") {
|
||||
t.Fatalf("%s", data)
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import (
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers/sqlite"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers/typeorm"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers/yaml"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/transform"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/writers"
|
||||
wbun "git.warky.dev/wdevs/relspecgo/pkg/writers/bun"
|
||||
wdbml "git.warky.dev/wdevs/relspecgo/pkg/writers/dbml"
|
||||
@@ -59,7 +60,9 @@ var (
|
||||
mergeSkipTables string // Comma-separated table names to skip
|
||||
mergeVerbose bool
|
||||
mergeReportPath string // Path to write merge report
|
||||
mergeFullDDL bool // Execute full DDL instead of diffing the live pgsql database
|
||||
mergeFlattenSchema bool
|
||||
mergeDryRun bool
|
||||
)
|
||||
|
||||
var mergeCmd = &cobra.Command{
|
||||
@@ -127,7 +130,9 @@ func init() {
|
||||
mergeCmd.Flags().BoolVar(&mergeSkipSequences, "skip-sequences", false, "Skip sequences during merge")
|
||||
mergeCmd.Flags().StringVar(&mergeSkipTables, "skip-tables", "", "Comma-separated list of table names to skip during merge")
|
||||
mergeCmd.Flags().BoolVar(&mergeVerbose, "verbose", false, "Show verbose output")
|
||||
mergeCmd.Flags().BoolVar(&mergeFullDDL, "full-ddl", false, "pgsql database output: execute the full DDL instead of only the differences from the live database")
|
||||
mergeCmd.Flags().StringVar(&mergeReportPath, "merge-report", "", "Path to write merge report (JSON format)")
|
||||
mergeCmd.Flags().BoolVar(&mergeDryRun, "dry-run", false, "Read and merge in memory, then print the merge plan without writing any output")
|
||||
mergeCmd.Flags().BoolVar(&mergeFlattenSchema, "flatten-schema", false, "Flatten schema.table names to schema_table (useful for databases like SQLite that do not support schemas)")
|
||||
}
|
||||
|
||||
@@ -248,6 +253,7 @@ func runMerge(cmd *cobra.Command, args []string) error {
|
||||
}
|
||||
|
||||
result := merge.MergeDatabases(targetDB, sourceDB, opts)
|
||||
finalizeCommentedRefs(targetDB, stderrWarn)
|
||||
|
||||
// Update timestamp
|
||||
targetDB.UpdateDate()
|
||||
@@ -261,6 +267,29 @@ func runMerge(cmd *cobra.Command, args []string) error {
|
||||
merge.GetColumnTypeConflictSummary(result, 10))
|
||||
}
|
||||
|
||||
if mergeDryRun {
|
||||
if !isMergeOutputFormat(mergeOutputType) {
|
||||
return fmt.Errorf("dry run validation failed: Output: unsupported format '%s'", mergeOutputType)
|
||||
}
|
||||
out := outWriter(cmd)
|
||||
fmt.Fprintf(out, "RelSpec merge plan (dry run - nothing written):\n")
|
||||
fmt.Fprintf(out, " Target: %s database '%s'\n", mergeTargetType, targetDB.Name)
|
||||
fmt.Fprintf(out, " Source: %s database '%s'\n", mergeSourceType, sourceDB.Name)
|
||||
switch {
|
||||
case mergeOutputPath != "":
|
||||
fmt.Fprintf(out, " Output: %s -> %s\n", mergeOutputType, mergeOutputPath)
|
||||
case mergeOutputConn != "":
|
||||
fmt.Fprintf(out, " Output: %s -> %s\n", mergeOutputType, maskPassword(mergeOutputConn))
|
||||
default:
|
||||
fmt.Fprintf(out, " Output: %s\n", mergeOutputType)
|
||||
}
|
||||
fmt.Fprintf(out, " Result:\n")
|
||||
printDryRunPlan(out, targetDB)
|
||||
fmt.Fprintf(out, "\n%s\n", merge.GetMergeSummary(result))
|
||||
fmt.Fprintf(os.Stderr, "\n=== Dry run complete: no output written ===\n")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Step 4: Write output
|
||||
fmt.Fprintf(os.Stderr, "\n[4/4] Writing output...\n")
|
||||
fmt.Fprintf(os.Stderr, " Format: %s\n", mergeOutputType)
|
||||
@@ -282,6 +311,16 @@ func runMerge(cmd *cobra.Command, args []string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// isMergeOutputFormat reports whether writeDatabaseForMerge supports dbType.
|
||||
func isMergeOutputFormat(dbType string) bool {
|
||||
switch strings.ToLower(dbType) {
|
||||
case "dbml", "dctx", "drawdb", "graphql", "json", "yaml", "gorm", "bun",
|
||||
"drizzle", "prisma", "typeorm", "sqlite", "sqlite3", "pgsql":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func readDatabaseForMerge(dbType, filePath, connString, label string) (*models.Database, error) {
|
||||
var reader readers.Reader
|
||||
|
||||
@@ -369,6 +408,14 @@ func readDatabaseForMerge(dbType, filePath, connString, label string) (*models.D
|
||||
}
|
||||
|
||||
func writeDatabaseForMerge(dbType, filePath, connString string, db *models.Database, label string, flattenSchema bool) error {
|
||||
if lowercaseIdentifiers {
|
||||
var err error
|
||||
db, err = transform.LowercaseIdentifiers(db)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
var writer writers.Writer
|
||||
|
||||
switch strings.ToLower(dbType) {
|
||||
@@ -442,6 +489,7 @@ func writeDatabaseForMerge(dbType, filePath, connString string, db *models.Datab
|
||||
if connString != "" {
|
||||
writerOpts.Metadata = map[string]interface{}{
|
||||
"connection_string": connString,
|
||||
"full_ddl": mergeFullDDL,
|
||||
}
|
||||
// Add report path if merge report is enabled
|
||||
if mergeReportPath != "" {
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestReadDatabaseForMerge(t *testing.T) {
|
||||
for _, tt := range readableFormats {
|
||||
t.Run(tt.format, func(t *testing.T) {
|
||||
db, err := readDatabaseForMerge(tt.format, filepath.Join(fixturesDir, tt.path), "", "Target")
|
||||
if err != nil {
|
||||
t.Skipf("format %s not supported by merge reader: %v", tt.format, err)
|
||||
}
|
||||
if db == nil || len(db.Schemas) == 0 {
|
||||
t.Errorf("no schemas: %+v", db)
|
||||
}
|
||||
})
|
||||
}
|
||||
for _, f := range []string{"dbml", "dctx", "drawdb", "graphql", "json", "yaml", "gorm", "bun", "drizzle", "prisma", "typeorm"} {
|
||||
if _, err := readDatabaseForMerge(f, "", "", "Src"); err == nil || !strings.Contains(err.Error(), "Src: file path is required") {
|
||||
t.Errorf("%s missing path: %v", f, err)
|
||||
}
|
||||
}
|
||||
if _, err := readDatabaseForMerge("pgsql", "", "", "Src"); err == nil || !strings.Contains(err.Error(), "Src:") {
|
||||
t.Errorf("pgsql: %v", err)
|
||||
}
|
||||
if _, err := readDatabaseForMerge("sqlite", "", "", "Src"); err == nil || !strings.Contains(err.Error(), "Src:") {
|
||||
t.Errorf("sqlite: %v", err)
|
||||
}
|
||||
if _, err := readDatabaseForMerge("nope", "x", "", "Src"); err == nil || !strings.Contains(err.Error(), "unsupported format 'nope'") {
|
||||
t.Errorf("unsupported: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteDatabaseForMerge(t *testing.T) {
|
||||
db := multiSchemaDB()
|
||||
single := multiSchemaDB()
|
||||
single.Schemas = single.Schemas[:1]
|
||||
|
||||
files := map[string]string{
|
||||
"dbml": "o.dbml", "dctx": "o.dctx", "drawdb": "o.drawdb.json", "graphql": "o.graphql",
|
||||
"json": "o.json", "yaml": "o.yaml", "gorm": "gorm.go", "bun": "bun.go",
|
||||
"drizzle": "o.ts", "prisma": "o.prisma", "typeorm": "te.ts",
|
||||
}
|
||||
for f, name := range files {
|
||||
t.Run(f, func(t *testing.T) {
|
||||
out := filepath.Join(t.TempDir(), name)
|
||||
if f == "dctx" {
|
||||
// DCTX cannot write a full database.
|
||||
if err := writeDatabaseForMerge(f, out, "", single, "Output", false); err == nil || !strings.Contains(err.Error(), "not supported for DCTX") {
|
||||
t.Errorf("dctx: %v", err)
|
||||
}
|
||||
if err := writeDatabaseForMerge(f, "", "", single, "Output", false); err == nil || !strings.Contains(err.Error(), "file path is required") {
|
||||
t.Errorf("dctx missing path: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
src := db
|
||||
if err := writeDatabaseForMerge(f, out, "", src, "Output", false); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(out); err != nil {
|
||||
t.Errorf("no output: %v", err)
|
||||
}
|
||||
if err := writeDatabaseForMerge(f, "", "", src, "Output", false); err == nil || !strings.Contains(err.Error(), "Output: file path is required") {
|
||||
t.Errorf("missing path: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
for _, f := range []string{"pgsql", "sqlite"} {
|
||||
out := filepath.Join(t.TempDir(), "o.sql")
|
||||
if err := writeDatabaseForMerge(f, out, "", db, "Output", false); err != nil {
|
||||
t.Errorf("%s script write: %v", f, err)
|
||||
}
|
||||
}
|
||||
if err := writeDatabaseForMerge("pgsql", "", "postgres://u:p@127.0.0.1:1/none?connect_timeout=1", db, "Output", false); err == nil {
|
||||
t.Error("pgsql with unreachable conn must fail")
|
||||
}
|
||||
if err := writeDatabaseForMerge("nope", "x", "", db, "Output", false); err == nil || !strings.Contains(err.Error(), "unsupported") {
|
||||
t.Errorf("unsupported: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsMergeOutputFormat(t *testing.T) {
|
||||
for _, f := range []string{"dbml", "JSON", "pgsql", "sqlite3", "prisma"} {
|
||||
if !isMergeOutputFormat(f) {
|
||||
t.Errorf("%s should be supported", f)
|
||||
}
|
||||
}
|
||||
for _, f := range []string{"", "nope", "mssql"} {
|
||||
if isMergeOutputFormat(f) {
|
||||
t.Errorf("%s should not be supported", f)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpandPath(t *testing.T) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
t.Skip("no home dir")
|
||||
}
|
||||
tests := []struct{ in, want string }{
|
||||
{"", ""},
|
||||
{"/abs/path", "/abs/path"},
|
||||
{"rel/path", "rel/path"},
|
||||
{"~/x/y", filepath.Join(home, "/x/y")},
|
||||
{"~", home},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := expandPath(tt.in); got != tt.want {
|
||||
t.Errorf("expandPath(%q) = %q, want %q", tt.in, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSkipTables(t *testing.T) {
|
||||
tests := []struct {
|
||||
in string
|
||||
want []string
|
||||
}{
|
||||
{"", nil},
|
||||
{" , ,", nil},
|
||||
{"Users", []string{"users"}},
|
||||
{" Users , ORDERS,,items ", []string{"users", "orders", "items"}},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := parseSkipTables(tt.in)
|
||||
if len(got) != len(tt.want) {
|
||||
t.Errorf("parseSkipTables(%q) = %v", tt.in, got)
|
||||
}
|
||||
for _, w := range tt.want {
|
||||
if !got[w] {
|
||||
t.Errorf("parseSkipTables(%q) missing %q", tt.in, w)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadDatabaseForInspect(t *testing.T) {
|
||||
for _, tt := range readableFormats {
|
||||
t.Run(tt.format, func(t *testing.T) {
|
||||
db, err := readDatabaseForInspect(tt.format, filepath.Join(fixturesDir, tt.path), "")
|
||||
if err != nil {
|
||||
t.Skipf("format %s not supported by inspect reader: %v", tt.format, err)
|
||||
}
|
||||
if db == nil || len(db.Schemas) == 0 {
|
||||
t.Errorf("no schemas: %+v", db)
|
||||
}
|
||||
})
|
||||
}
|
||||
for _, f := range []string{"dbml", "dctx", "drawdb", "graphql", "json", "yaml", "gorm", "bun", "drizzle", "prisma", "typeorm"} {
|
||||
if _, err := readDatabaseForInspect(f, "", ""); err == nil || !strings.Contains(err.Error(), "file path is required") {
|
||||
t.Errorf("%s missing path: %v", f, err)
|
||||
}
|
||||
}
|
||||
if _, err := readDatabaseForInspect("pgsql", "", ""); err == nil {
|
||||
t.Error("pgsql without conn must fail")
|
||||
}
|
||||
if _, err := readDatabaseForInspect("nope", "x", ""); err == nil || !strings.Contains(err.Error(), "unsupported database type") {
|
||||
t.Errorf("unsupported: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterDatabaseBySchema(t *testing.T) {
|
||||
db := multiSchemaDB()
|
||||
db.Description = "desc"
|
||||
got := filterDatabaseBySchema(db, "b")
|
||||
if len(got.Schemas) != 1 || got.Schemas[0].Name != "b" || got.Name != db.Name || got.Description != "desc" {
|
||||
t.Errorf("filtered: %+v", got)
|
||||
}
|
||||
if got := filterDatabaseBySchema(db, "zzz"); len(got.Schemas) != 0 {
|
||||
t.Errorf("missing schema should yield no schemas: %+v", got.Schemas)
|
||||
}
|
||||
if len(db.Schemas) != 2 {
|
||||
t.Error("input mutated")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasSilentFlag(t *testing.T) {
|
||||
tests := []struct {
|
||||
args []string
|
||||
want bool
|
||||
}{
|
||||
{nil, false},
|
||||
{[]string{"convert"}, false},
|
||||
{[]string{"convert", "--silent"}, true},
|
||||
{[]string{"--silent=true"}, true},
|
||||
{[]string{"--silent=false"}, false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := hasSilentFlag(tt.args); got != tt.want {
|
||||
t.Errorf("hasSilentFlag(%v) = %v", tt.args, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintVersionHeader(t *testing.T) {
|
||||
capture := func(args []string) string {
|
||||
old := os.Stdout
|
||||
r, w, _ := os.Pipe()
|
||||
os.Stdout = w
|
||||
printVersionHeader(args)
|
||||
w.Close()
|
||||
os.Stdout = old
|
||||
b := make([]byte, 4096)
|
||||
n, _ := r.Read(b)
|
||||
return string(b[:n])
|
||||
}
|
||||
if out := capture([]string{"convert"}); !strings.HasPrefix(out, "RelSpec ") {
|
||||
t.Errorf("header: %q", out)
|
||||
}
|
||||
if out := capture([]string{"convert", "--no-version"}); out != "" {
|
||||
t.Errorf("--no-version: %q", out)
|
||||
}
|
||||
if out := capture([]string{"version"}); out != "" {
|
||||
t.Errorf("version cmd: %q", out)
|
||||
}
|
||||
if out := capture(nil); !strings.HasPrefix(out, "RelSpec ") {
|
||||
t.Errorf("no args: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReportState(t *testing.T) {
|
||||
cfg := t.TempDir()
|
||||
t.Setenv("XDG_CONFIG_HOME", cfg)
|
||||
t.Setenv("HOME", cfg)
|
||||
|
||||
dir, err := reportStateDir()
|
||||
if err != nil || !strings.HasPrefix(dir, cfg) {
|
||||
t.Fatalf("dir: %q %v", dir, err)
|
||||
}
|
||||
|
||||
state, path, err := loadReportState()
|
||||
if err != nil || !state.LastReport.IsZero() || state.MachineID != "" {
|
||||
t.Fatalf("fresh state: %+v %v", state, err)
|
||||
}
|
||||
|
||||
want := reportState{LastReport: time.Now().UTC().Truncate(time.Second), MachineID: "abc"}
|
||||
if err := saveReportState(path, want); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, _, err := loadReportState()
|
||||
if err != nil || !got.LastReport.Equal(want.LastReport) || got.MachineID != "abc" {
|
||||
t.Errorf("round trip: %+v %v", got, err)
|
||||
}
|
||||
|
||||
// Corrupt state is ignored.
|
||||
if err := os.WriteFile(path, []byte("{bad"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, _, err := loadReportState(); err != nil || got.MachineID != "" {
|
||||
t.Errorf("corrupt: %+v %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSystemUniqueID_NonEmpty(t *testing.T) {
|
||||
cfg := t.TempDir()
|
||||
t.Setenv("XDG_CONFIG_HOME", cfg)
|
||||
state, path, _ := loadReportState()
|
||||
id, err := systemUniqueID(state, path)
|
||||
if err != nil || id == "" {
|
||||
t.Errorf("id: %q %v", id, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReportToken_Decodes(t *testing.T) {
|
||||
if _, err := reportToken(); err != nil {
|
||||
t.Errorf("token must decode: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubmitReport_RateLimited(t *testing.T) {
|
||||
cfg := t.TempDir()
|
||||
t.Setenv("XDG_CONFIG_HOME", cfg)
|
||||
_, path, _ := loadReportState()
|
||||
if err := saveReportState(path, reportState{LastReport: time.Now()}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Rate limit rejects before any network call is made.
|
||||
if err := submitReport("bug", "t", "b", "", ""); err == nil || !strings.Contains(err.Error(), "please wait") {
|
||||
t.Errorf("got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -22,13 +22,16 @@ func newReaderOptions(filePath, connString string) *readers.ReaderOptions {
|
||||
|
||||
func newWriterOptions(outputPath, packageName string, flattenSchema bool, nullableTypes, nullableArrays string, continueOnError bool) *writers.WriterOptions {
|
||||
return &writers.WriterOptions{
|
||||
OutputPath: outputPath,
|
||||
PackageName: packageName,
|
||||
FlattenSchema: flattenSchema,
|
||||
NullableTypes: nullableTypes,
|
||||
NullableArrays: nullableArrays,
|
||||
Prisma7: prisma7,
|
||||
ContinueOnError: continueOnError,
|
||||
StrictDirectives: strictDirectives,
|
||||
OutputPath: outputPath,
|
||||
PackageName: packageName,
|
||||
FlattenSchema: flattenSchema,
|
||||
LowercaseIdentifiers: lowercaseIdentifiers,
|
||||
NullableTypes: nullableTypes,
|
||||
NullableArrays: nullableArrays,
|
||||
TypeMappings: typeMappings,
|
||||
Prisma7: prisma7,
|
||||
ContinueOnError: continueOnError,
|
||||
StrictDirectives: strictDirectives,
|
||||
StrictConversion: strictConversion,
|
||||
}
|
||||
}
|
||||
|
||||
+24
-6
@@ -6,17 +6,22 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/buildinfo"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/writers"
|
||||
)
|
||||
|
||||
// version/buildDate mirror pkg/buildinfo so existing call sites keep working.
|
||||
// The actual values are set there via ldflags (see Makefile).
|
||||
var (
|
||||
version = buildinfo.Version
|
||||
buildDate = buildinfo.BuildDate
|
||||
prisma7 bool
|
||||
noVersion bool
|
||||
silent bool
|
||||
strictDirectives bool
|
||||
version = buildinfo.Version
|
||||
buildDate = buildinfo.BuildDate
|
||||
prisma7 bool
|
||||
noVersion bool
|
||||
silent bool
|
||||
strictDirectives bool
|
||||
strictConversion bool
|
||||
lowercaseIdentifiers bool
|
||||
typeMapFlags []string
|
||||
typeMappings map[string]string
|
||||
)
|
||||
|
||||
var rootCmd = &cobra.Command{
|
||||
@@ -28,24 +33,37 @@ bidirectional conversion between various database schema formats.
|
||||
It reads database schemas from multiple sources (live databases, DBML,
|
||||
DCTX, DrawDB, etc.) and writes them to various formats (GORM, Bun,
|
||||
JSON, YAML, SQL, etc.).`,
|
||||
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
|
||||
var err error
|
||||
typeMappings, err = writers.ParseTypeMappings(typeMapFlags)
|
||||
return err
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
for _, cmd := range []*cobra.Command{convertCmd, batchCmd, mergeCmd, splitCmd, templCmd} {
|
||||
cmd.Flags().BoolVar(&lowercaseIdentifiers, "lowercase", false, "Lowercase all model identifiers and structured references before output (SQL expressions and data keep their case)")
|
||||
}
|
||||
rootCmd.AddCommand(convertCmd)
|
||||
rootCmd.AddCommand(batchCmd)
|
||||
rootCmd.AddCommand(diffCmd)
|
||||
rootCmd.AddCommand(inspectCmd)
|
||||
rootCmd.AddCommand(scriptsCmd)
|
||||
rootCmd.AddCommand(jobCmd)
|
||||
jobCmd.PersistentFlags().BoolVar(&lowercaseIdentifiers, "lowercase", false, "Lowercase all model identifiers and structured references before schema output")
|
||||
rootCmd.AddCommand(assetsCmd)
|
||||
rootCmd.AddCommand(templCmd)
|
||||
rootCmd.AddCommand(editCmd)
|
||||
rootCmd.AddCommand(mergeCmd)
|
||||
rootCmd.AddCommand(splitCmd)
|
||||
rootCmd.AddCommand(versionCmd)
|
||||
rootCmd.AddCommand(updateCmd)
|
||||
rootCmd.AddCommand(reportCmd)
|
||||
rootCmd.PersistentFlags().BoolVar(&prisma7, "prisma7", false, "Use Prisma 7 generator conventions when reading/writing Prisma schemas")
|
||||
rootCmd.PersistentFlags().StringArrayVar(&typeMapFlags, "type-map", nil, "Override a SQL-to-Go type mapping for bun/gorm output as sqltype=gotype (repeatable), e.g. --type-map uuid=uuid.UUID --type-map numeric=decimal.Decimal")
|
||||
rootCmd.PersistentFlags().BoolVar(&noVersion, "no-version", false, "Suppress the RelSpec version header")
|
||||
rootCmd.PersistentFlags().BoolVar(&silent, "silent", false, "Suppress progress and status messages (errors are still shown)")
|
||||
rootCmd.PersistentFlags().BoolVar(&strictConversion, "strict-conversion", false, "Reject known lossy MySQL schema conversions before writing or executing output")
|
||||
rootCmd.PersistentFlags().BoolVar(&strictDirectives, "strict-directives", false, "Fail on unknown or untranslatable DBML dialect directives (@postgres:, @sqlite:, …)")
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRunDiff(t *testing.T) {
|
||||
oldS, oldSP, oldSC, oldT, oldTP, oldTC, oldF, oldO := sourceType, sourcePath, sourceConn, targetType, targetPath, targetConn, outputFormat, outputPath
|
||||
t.Cleanup(func() {
|
||||
sourceType, sourcePath, sourceConn, targetType, targetPath, targetConn, outputFormat, outputPath = oldS, oldSP, oldSC, oldT, oldTP, oldTC, oldF, oldO
|
||||
})
|
||||
|
||||
src := filepath.Join(fixturesDir, "dbml/simple.dbml")
|
||||
cmplx := filepath.Join(fixturesDir, "dbml/complex.dbml")
|
||||
|
||||
for _, format := range []string{"summary", "json", "html"} {
|
||||
t.Run(format, func(t *testing.T) {
|
||||
sourceType, sourcePath, sourceConn = "dbml", src, ""
|
||||
targetType, targetPath, targetConn = "dbml", cmplx, ""
|
||||
outputFormat = format
|
||||
outputPath = filepath.Join(t.TempDir(), "diff.out")
|
||||
if format == "summary" {
|
||||
outputPath = ""
|
||||
}
|
||||
if err := runDiff(nil, nil); err != nil {
|
||||
t.Fatalf("runDiff: %v", err)
|
||||
}
|
||||
if outputPath != "" {
|
||||
if b, err := os.ReadFile(outputPath); err != nil || len(b) == 0 {
|
||||
t.Errorf("empty output: %v", err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("bad source", func(t *testing.T) {
|
||||
sourceType, sourcePath = "dbml", filepath.Join(t.TempDir(), "missing.dbml")
|
||||
targetType, targetPath = "dbml", src
|
||||
outputFormat, outputPath = "summary", ""
|
||||
if err := runDiff(nil, nil); err == nil || !strings.Contains(err.Error(), "failed to read source database") {
|
||||
t.Errorf("got %v", err)
|
||||
}
|
||||
})
|
||||
t.Run("bad target", func(t *testing.T) {
|
||||
sourceType, sourcePath = "dbml", src
|
||||
targetType, targetPath = "dbml", filepath.Join(t.TempDir(), "missing.dbml")
|
||||
outputFormat, outputPath = "summary", ""
|
||||
if err := runDiff(nil, nil); err == nil || !strings.Contains(err.Error(), "failed to read target database") {
|
||||
t.Errorf("got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestRunInspect(t *testing.T) {
|
||||
oldT, oldP, oldC, oldR, oldF, oldO, oldS := inspectSourceType, inspectSourcePath, inspectSourceConn, inspectRulesPath, inspectOutputFormat, inspectOutputPath, inspectSchemaFilter
|
||||
t.Cleanup(func() {
|
||||
inspectSourceType, inspectSourcePath, inspectSourceConn, inspectRulesPath, inspectOutputFormat, inspectOutputPath, inspectSchemaFilter = oldT, oldP, oldC, oldR, oldF, oldO, oldS
|
||||
})
|
||||
|
||||
inspectSourceType = "dbml"
|
||||
inspectSourcePath = filepath.Join(fixturesDir, "dbml/simple.dbml")
|
||||
inspectSourceConn = ""
|
||||
inspectRulesPath = filepath.Join(t.TempDir(), "no-rules.yaml") // missing: defaults used or error
|
||||
inspectSchemaFilter = ""
|
||||
|
||||
// Whatever the rules outcome, the run must not panic; formats are exercised.
|
||||
for _, format := range []string{"markdown", "json"} {
|
||||
inspectOutputFormat = format
|
||||
inspectOutputPath = filepath.Join(t.TempDir(), "report."+format)
|
||||
_ = runInspect(nil, nil)
|
||||
}
|
||||
|
||||
inspectOutputFormat = "bogus"
|
||||
inspectOutputPath = ""
|
||||
if err := runInspect(nil, nil); err == nil {
|
||||
t.Error("bogus output format must fail")
|
||||
}
|
||||
|
||||
inspectSourcePath = filepath.Join(t.TempDir(), "missing.dbml")
|
||||
if err := runInspect(nil, nil); err == nil || !strings.Contains(err.Error(), "failed to read source") {
|
||||
t.Errorf("missing source: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -63,7 +63,7 @@ var scriptsExecuteCmd = &cobra.Command{
|
||||
Long: `Execute SQL scripts from a directory against a PostgreSQL database.
|
||||
|
||||
Scripts are executed in order: Priority (ascending), Sequence (ascending), Name (alphabetical).
|
||||
By default, execution stops immediately on the first error. Use --ignore-errors to continue execution.
|
||||
By default, execution stops immediately on the first error. Use --ignore-errors (or --continue-on-error / --coe) to attempt later scripts; any failure still exits nonzero.
|
||||
|
||||
The directory is scanned recursively for all subdirectories and files matching the patterns:
|
||||
{priority}_{sequence}_{name}.sql or .pgsql (underscore format)
|
||||
@@ -112,6 +112,8 @@ func init() {
|
||||
scriptsExecuteCmd.Flags().StringVar(&scriptsSchemaName, "schema", "public", "Schema name (optional, default: public)")
|
||||
scriptsExecuteCmd.Flags().StringVar(&scriptsDBName, "database", "database", "Database name (optional, default: database)")
|
||||
scriptsExecuteCmd.Flags().BoolVar(&scriptsIgnoreErrors, "ignore-errors", false, "Continue executing scripts even if errors occur")
|
||||
scriptsExecuteCmd.Flags().BoolVar(&scriptsIgnoreErrors, "continue-on-error", false, "Attempt later scripts after failures; still exit nonzero")
|
||||
scriptsExecuteCmd.Flags().BoolVar(&scriptsIgnoreErrors, "coe", false, "Alias for --continue-on-error")
|
||||
|
||||
err = scriptsExecuteCmd.MarkFlagRequired("dir")
|
||||
if err != nil {
|
||||
|
||||
@@ -24,6 +24,7 @@ var (
|
||||
splitExcludeTables string
|
||||
splitNullableTypes string
|
||||
splitNullableArrays string
|
||||
splitDryRun bool
|
||||
)
|
||||
|
||||
var splitCmd = &cobra.Command{
|
||||
@@ -115,6 +116,8 @@ func init() {
|
||||
splitCmd.Flags().StringVar(&splitNullableTypes, "types", "", "Nullable type package for code-gen writers (bun/gorm): 'baselib' (default, Go pointer types), 'stdlib' (database/sql), or 'sqltypes'")
|
||||
splitCmd.Flags().StringVar(&splitNullableArrays, "array-nullable", "", "Nullable PostgreSQL array representation for the Bun writer in stdlib/baselib --types mode: 'slice' (default, plain slice) or 'pointer_slice' (*[]T, distinguishes NULL from '{}')")
|
||||
|
||||
splitCmd.Flags().BoolVar(&splitDryRun, "dry-run", false, "Read, filter and validate the selection and print the plan without writing any output")
|
||||
|
||||
err := splitCmd.MarkFlagRequired("from")
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error marking from flag as required: %v\n", err)
|
||||
@@ -174,6 +177,26 @@ func runSplit(cmd *cobra.Command, args []string) error {
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, " ✓ Filtered to: %d schema(s), %d table(s)\n\n", len(filteredDB.Schemas), filteredTables)
|
||||
|
||||
if splitDryRun {
|
||||
if err := validateWriteTarget(filteredDB, splitTargetType, splitPackageName, "", ""); err != nil {
|
||||
return fmt.Errorf("dry run validation failed: %w", err)
|
||||
}
|
||||
out := outWriter(cmd)
|
||||
fmt.Fprintf(out, "RelSpec split plan (dry run - nothing written):\n")
|
||||
fmt.Fprintf(out, " Input: %s database '%s'\n", splitSourceType, db.Name)
|
||||
fmt.Fprintf(out, " Output: %s -> %s\n", splitTargetType, splitTargetPath)
|
||||
fmt.Fprintf(out, " Selection: %s\n", splitSelection{
|
||||
Schemas: parseCommaSeparated(splitSchemas),
|
||||
Tables: parseCommaSeparated(splitTables),
|
||||
ExcludeSchemas: parseCommaSeparated(splitExcludeSchema),
|
||||
ExcludeTables: parseCommaSeparated(splitExcludeTables),
|
||||
DatabaseName: splitDatabaseName,
|
||||
}.summary())
|
||||
printDryRunPlan(out, filteredDB)
|
||||
fmt.Fprintf(os.Stderr, "=== Dry run complete: no output written ===\n\n")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Write to target format
|
||||
fmt.Fprintf(os.Stderr, "[3/3] Writing to target format...\n")
|
||||
fmt.Fprintf(os.Stderr, " Format: %s\n", splitTargetType)
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/jobs"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||
)
|
||||
|
||||
func TestStrictConversionOptionAndDryRun(t *testing.T) {
|
||||
prior := strictConversion
|
||||
strictConversion = true
|
||||
t.Cleanup(func() { strictConversion = prior })
|
||||
if flag := rootCmd.PersistentFlags().Lookup("strict-conversion"); flag == nil {
|
||||
t.Fatal("missing --strict-conversion")
|
||||
}
|
||||
db := models.InitDatabase("shop")
|
||||
s := models.InitSchema("shop")
|
||||
table := models.InitTable("users", "shop")
|
||||
col := models.InitColumn("value", "users", "shop")
|
||||
col.Type = "unknown_type"
|
||||
table.Columns[col.Name] = col
|
||||
s.Tables = append(s.Tables, table)
|
||||
db.Schemas = append(db.Schemas, s)
|
||||
if err := validateWriteTarget(db, "mysql", "", "", ""); err == nil || !strings.Contains(err.Error(), "lossy MySQL") {
|
||||
t.Fatalf("dry run bypassed strict validation: %v", err)
|
||||
}
|
||||
strictConversion = false
|
||||
out := filepath.Join(t.TempDir(), "out.sql")
|
||||
rj := &resolvedJob{job: &jobs.Job{Output: &jobs.Output{Format: "mysql"}, Options: jobs.Options{StrictConversion: true}}, outputPath: out}
|
||||
if err := writeJobOutput(rj, db, &jobLogger{file: io.Discard}); err == nil || !strings.Contains(err.Error(), "lossy MySQL") {
|
||||
t.Fatalf("job option bypassed strict validation: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(out); !os.IsNotExist(err) {
|
||||
t.Fatalf("strict job created output: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/transform"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/writers"
|
||||
wtemplate "git.warky.dev/wdevs/relspecgo/pkg/writers/template"
|
||||
)
|
||||
@@ -114,6 +115,7 @@ func runTempl(cmd *cobra.Command, args []string) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read source: %w", err)
|
||||
}
|
||||
finalizeCommentedRefs(db, stderrWarn)
|
||||
|
||||
// Print database stats
|
||||
schemaCount := len(db.Schemas)
|
||||
@@ -141,6 +143,13 @@ func runTempl(cmd *cobra.Command, args []string) error {
|
||||
}
|
||||
}
|
||||
|
||||
if lowercaseIdentifiers {
|
||||
db, err = transform.LowercaseIdentifiers(db)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Create template writer
|
||||
fmt.Fprintf(os.Stderr, "Loading template: %s\n", templTemplatePath)
|
||||
fmt.Fprintf(os.Stderr, "Execution mode: %s\n", templMode)
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/buildinfo"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/updatecheck"
|
||||
)
|
||||
|
||||
// windowsInstallerAsset is the release asset built by the NSIS installer step.
|
||||
const windowsInstallerAsset = "relspec-setup-windows-amd64.exe"
|
||||
|
||||
var (
|
||||
updateCheckOnly bool
|
||||
updateAssumeYes bool
|
||||
updateAPIURL = updatecheck.DefaultAPIURL
|
||||
)
|
||||
|
||||
var updateCmd = &cobra.Command{
|
||||
Use: "update",
|
||||
Short: "Check for a newer release and offer to install it",
|
||||
Long: `Check the project releases for a version newer than this binary.
|
||||
|
||||
If one exists you are prompted to update. On Windows the installer is
|
||||
downloaded and started; on other platforms the release page URL is shown.
|
||||
Use --check to only report, and --yes to skip the prompt.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx, cancel := context.WithTimeout(cmd.Context(), 10*time.Minute)
|
||||
defer cancel()
|
||||
return runUpdate(ctx, updateOptions{
|
||||
current: buildinfo.Version,
|
||||
apiURL: updateAPIURL,
|
||||
goos: runtime.GOOS,
|
||||
checkOnly: updateCheckOnly,
|
||||
assumeYes: updateAssumeYes,
|
||||
in: cmd.InOrStdin(),
|
||||
out: cmd.OutOrStdout(),
|
||||
install: downloadAndRunInstaller,
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
updateCmd.Flags().BoolVar(&updateCheckOnly, "check", false, "Only report whether an update is available")
|
||||
updateCmd.Flags().BoolVarP(&updateAssumeYes, "yes", "y", false, "Update without prompting")
|
||||
}
|
||||
|
||||
type updateOptions struct {
|
||||
current string
|
||||
apiURL string
|
||||
goos string
|
||||
checkOnly bool
|
||||
assumeYes bool
|
||||
in io.Reader
|
||||
out io.Writer
|
||||
// install downloads and starts the installer found at url.
|
||||
install func(ctx context.Context, url string, out io.Writer) error
|
||||
}
|
||||
|
||||
func runUpdate(ctx context.Context, o updateOptions) error {
|
||||
rel, err := updatecheck.Latest(ctx, nil, o.apiURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !updatecheck.IsNewer(o.current, rel.Tag) {
|
||||
_, _ = fmt.Fprintf(o.out, "RelSpec %s is up to date (latest release: %s)\n", o.current, rel.Tag)
|
||||
return nil
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintf(o.out, "A newer RelSpec is available: %s (installed: %s)\n", rel.Tag, o.current)
|
||||
if o.checkOnly {
|
||||
_, _ = fmt.Fprintf(o.out, "Release: %s\n", rel.URL)
|
||||
return nil
|
||||
}
|
||||
|
||||
installer, canInstall := rel.FindAsset(windowsInstallerAsset)
|
||||
if o.goos != "windows" || !canInstall {
|
||||
_, _ = fmt.Fprintf(o.out, "Download it from: %s\n", rel.URL)
|
||||
return nil
|
||||
}
|
||||
|
||||
if !o.assumeYes && !confirm(o.in, o.out, "Download and run the installer now?") {
|
||||
_, _ = fmt.Fprintf(o.out, "Skipped. Release: %s\n", rel.URL)
|
||||
return nil
|
||||
}
|
||||
return o.install(ctx, installer.URL, o.out)
|
||||
}
|
||||
|
||||
// confirm asks a yes/no question, defaulting to no.
|
||||
func confirm(in io.Reader, out io.Writer, question string) bool {
|
||||
_, _ = fmt.Fprintf(out, "%s [y/N]: ", question)
|
||||
line, _ := bufio.NewReader(in).ReadString('\n')
|
||||
switch strings.ToLower(strings.TrimSpace(line)) {
|
||||
case "y", "yes":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// downloadAndRunInstaller saves the installer to a temp directory and starts
|
||||
// it detached so this process can exit and release relspec.exe.
|
||||
func downloadAndRunInstaller(ctx context.Context, url string, out io.Writer) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("downloading installer: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("downloading installer: unexpected status %s", resp.Status)
|
||||
}
|
||||
|
||||
dir, err := os.MkdirTemp("", "relspec-update-")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
path := filepath.Join(dir, windowsInstallerAsset)
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := io.Copy(f, resp.Body); err != nil {
|
||||
_ = f.Close()
|
||||
return fmt.Errorf("downloading installer: %w", err)
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintf(out, "Starting installer: %s\n", path)
|
||||
return exec.Command(path).Start()
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func releaseServer(t *testing.T, tag string, withInstaller bool) *httptest.Server {
|
||||
t.Helper()
|
||||
assets := ""
|
||||
if withInstaller {
|
||||
assets = fmt.Sprintf(`{"name":%q,"browser_download_url":"https://x/setup.exe"}`, windowsInstallerAsset)
|
||||
}
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = fmt.Fprintf(w, `{"tag_name":%q,"html_url":"https://x/release","assets":[%s]}`, tag, assets)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
return srv
|
||||
}
|
||||
|
||||
func TestRunUpdate(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
current string
|
||||
latest string
|
||||
withInstaller bool
|
||||
goos string
|
||||
checkOnly bool
|
||||
assumeYes bool
|
||||
stdin string
|
||||
wantOut []string
|
||||
wantInstall bool
|
||||
}{
|
||||
{name: "up to date", current: "v1.0.5", latest: "v1.0.5", goos: "windows", withInstaller: true, wantOut: []string{"up to date"}},
|
||||
{name: "dev build never prompts", current: "dev", latest: "v9.0.0", goos: "windows", withInstaller: true, wantOut: []string{"up to date"}},
|
||||
{name: "check only", current: "v1.0.5", latest: "v1.0.6", goos: "windows", withInstaller: true, checkOnly: true, wantOut: []string{"v1.0.6", "https://x/release"}},
|
||||
{name: "non-windows shows url", current: "v1.0.5", latest: "v1.0.6", goos: "linux", withInstaller: true, wantOut: []string{"Download it from: https://x/release"}},
|
||||
{name: "windows without installer asset", current: "v1.0.5", latest: "v1.0.6", goos: "windows", wantOut: []string{"Download it from"}},
|
||||
{name: "windows prompt yes", current: "v1.0.5", latest: "v1.0.6", goos: "windows", withInstaller: true, stdin: "y\n", wantOut: []string{"[y/N]"}, wantInstall: true},
|
||||
{name: "windows prompt no", current: "v1.0.5", latest: "v1.0.6", goos: "windows", withInstaller: true, stdin: "n\n", wantOut: []string{"Skipped"}},
|
||||
{name: "windows prompt empty", current: "v1.0.5", latest: "v1.0.6", goos: "windows", withInstaller: true, wantOut: []string{"Skipped"}},
|
||||
{name: "windows --yes", current: "v1.0.5", latest: "v1.0.6", goos: "windows", withInstaller: true, assumeYes: true, wantInstall: true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
srv := releaseServer(t, tt.latest, tt.withInstaller)
|
||||
var out bytes.Buffer
|
||||
var installedURL string
|
||||
err := runUpdate(context.Background(), updateOptions{
|
||||
current: tt.current, apiURL: srv.URL, goos: tt.goos,
|
||||
checkOnly: tt.checkOnly, assumeYes: tt.assumeYes,
|
||||
in: strings.NewReader(tt.stdin), out: &out,
|
||||
install: func(_ context.Context, url string, _ io.Writer) error {
|
||||
installedURL = url
|
||||
return nil
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, want := range tt.wantOut {
|
||||
if !strings.Contains(out.String(), want) {
|
||||
t.Errorf("output missing %q:\n%s", want, out.String())
|
||||
}
|
||||
}
|
||||
if (installedURL != "") != tt.wantInstall {
|
||||
t.Errorf("installer called = %v, want %v", installedURL != "", tt.wantInstall)
|
||||
}
|
||||
if tt.wantInstall && installedURL != "https://x/setup.exe" {
|
||||
t.Errorf("installer url = %q", installedURL)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunUpdate_Errors(t *testing.T) {
|
||||
bad := httptest.NewServer(http.NotFoundHandler())
|
||||
defer bad.Close()
|
||||
if err := runUpdate(context.Background(), updateOptions{current: "v1.0.0", apiURL: bad.URL, out: io.Discard}); err == nil {
|
||||
t.Error("expected lookup error")
|
||||
}
|
||||
|
||||
srv := releaseServer(t, "v1.0.6", true)
|
||||
want := errors.New("boom")
|
||||
err := runUpdate(context.Background(), updateOptions{
|
||||
current: "v1.0.5", apiURL: srv.URL, goos: "windows", assumeYes: true, out: io.Discard,
|
||||
install: func(context.Context, string, io.Writer) error { return want },
|
||||
})
|
||||
if !errors.Is(err, want) {
|
||||
t.Errorf("err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadAndRunInstaller_BadStatus(t *testing.T) {
|
||||
srv := httptest.NewServer(http.NotFoundHandler())
|
||||
defer srv.Close()
|
||||
if err := downloadAndRunInstaller(context.Background(), srv.URL, io.Discard); err == nil {
|
||||
t.Error("expected error for non-200")
|
||||
}
|
||||
if err := downloadAndRunInstaller(context.Background(), "://bad", io.Discard); err == nil {
|
||||
t.Error("expected error for bad url")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
// watchSnapshot maps a file path to its modification time and size.
|
||||
type watchSnapshot map[string]string
|
||||
|
||||
// takeWatchSnapshot records the state of every file under the given paths.
|
||||
// Directories are walked recursively. Anything at or below the excluded path
|
||||
// (typically the output path) is skipped so regenerating output does not
|
||||
// retrigger the watcher. Missing paths are simply absent from the snapshot, so
|
||||
// creating them later counts as a change.
|
||||
func takeWatchSnapshot(paths []string, exclude string) watchSnapshot {
|
||||
snap := watchSnapshot{}
|
||||
exclude = absPathOrSelf(exclude)
|
||||
for _, root := range paths {
|
||||
_ = filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if exclude != "" && isWithin(absPathOrSelf(p), exclude) {
|
||||
if d.IsDir() {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
info, err := d.Info()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
snap[p] = fmt.Sprintf("%d-%d", info.ModTime().UnixNano(), info.Size())
|
||||
return nil
|
||||
})
|
||||
}
|
||||
return snap
|
||||
}
|
||||
|
||||
func (s watchSnapshot) equal(o watchSnapshot) bool {
|
||||
if len(s) != len(o) {
|
||||
return false
|
||||
}
|
||||
for k, v := range s {
|
||||
if o[k] != v {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func absPathOrSelf(p string) string {
|
||||
if p == "" {
|
||||
return ""
|
||||
}
|
||||
if abs, err := filepath.Abs(p); err == nil {
|
||||
return abs
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// isWithin reports whether path equals dir or is located below it.
|
||||
func isWithin(path, dir string) bool {
|
||||
if path == dir {
|
||||
return true
|
||||
}
|
||||
return strings.HasPrefix(path, dir+string(filepath.Separator))
|
||||
}
|
||||
|
||||
// watchLoop runs fn once immediately and again whenever the watched paths
|
||||
// change, until ctx is cancelled. Changes are debounced: fn runs only after
|
||||
// the snapshot has stayed unchanged for one poll interval. Errors from fn are
|
||||
// reported to w and do not stop the loop.
|
||||
func watchLoop(ctx context.Context, w io.Writer, paths []string, exclude string, interval time.Duration, fn func() error) {
|
||||
run := func() {
|
||||
if err := fn(); err != nil {
|
||||
fmt.Fprintf(w, "Error: %v\n", err)
|
||||
}
|
||||
fmt.Fprintf(w, "Watching for changes (Ctrl-C to stop)...\n")
|
||||
}
|
||||
|
||||
last := takeWatchSnapshot(paths, exclude)
|
||||
run()
|
||||
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
}
|
||||
cur := takeWatchSnapshot(paths, exclude)
|
||||
if cur.equal(last) {
|
||||
continue
|
||||
}
|
||||
// Debounce: wait until writes settle.
|
||||
for settled := false; !settled; {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(interval):
|
||||
}
|
||||
next := takeWatchSnapshot(paths, exclude)
|
||||
settled = next.equal(cur)
|
||||
cur = next
|
||||
}
|
||||
fmt.Fprintf(w, "\nChange detected, regenerating...\n")
|
||||
last = cur
|
||||
run()
|
||||
}
|
||||
}
|
||||
|
||||
// runConvertWatch runs the conversion once and then again whenever the source
|
||||
// files change, until interrupted.
|
||||
func runConvertWatch(parent context.Context, w io.Writer, run func() error) error {
|
||||
var paths []string
|
||||
switch {
|
||||
case len(convertFromList) > 0:
|
||||
paths = convertFromList
|
||||
case convertSourcePath != "":
|
||||
paths = []string{convertSourcePath}
|
||||
default:
|
||||
return fmt.Errorf("--watch requires --from-path or --from-list (live database connections cannot be watched)")
|
||||
}
|
||||
if convertDryRun {
|
||||
return fmt.Errorf("--watch cannot be combined with --dry-run")
|
||||
}
|
||||
if convertWatchInterval <= 0 {
|
||||
return fmt.Errorf("--watch-interval must be positive")
|
||||
}
|
||||
if parent == nil {
|
||||
parent = context.Background()
|
||||
}
|
||||
|
||||
ctx, stop := signal.NotifyContext(parent, os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
watchLoop(ctx, w, paths, convertTargetPath, convertWatchInterval, run)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestWatchSnapshotExcludesOutput(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
out := filepath.Join(dir, "out")
|
||||
if err := os.MkdirAll(out, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
src := filepath.Join(dir, "schema.dbml")
|
||||
if err := os.WriteFile(src, []byte("a"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before := takeWatchSnapshot([]string{dir}, out)
|
||||
if err := os.WriteFile(filepath.Join(out, "gen.go"), []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if after := takeWatchSnapshot([]string{dir}, out); !before.equal(after) {
|
||||
t.Errorf("writing into the excluded output path changed the snapshot")
|
||||
}
|
||||
if err := os.WriteFile(src, []byte("changed"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if after := takeWatchSnapshot([]string{dir}, out); before.equal(after) {
|
||||
t.Errorf("modifying a source file did not change the snapshot")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWatchLoopRerunsOnChange(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
src := filepath.Join(dir, "schema.dbml")
|
||||
if err := os.WriteFile(src, []byte("a"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var runs atomic.Int32
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
watchLoop(ctx, io.Discard, []string{src}, "", 10*time.Millisecond, func() error {
|
||||
runs.Add(1)
|
||||
return nil
|
||||
})
|
||||
}()
|
||||
|
||||
waitFor(t, func() bool { return runs.Load() == 1 })
|
||||
if err := os.WriteFile(src, []byte("changed content"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
waitFor(t, func() bool { return runs.Load() == 2 })
|
||||
cancel()
|
||||
<-done
|
||||
}
|
||||
|
||||
func TestRunConvertWatchValidation(t *testing.T) {
|
||||
oldPath, oldList, oldDry, oldInt := convertSourcePath, convertFromList, convertDryRun, convertWatchInterval
|
||||
defer func() {
|
||||
convertSourcePath, convertFromList, convertDryRun, convertWatchInterval = oldPath, oldList, oldDry, oldInt
|
||||
}()
|
||||
convertSourcePath, convertFromList, convertDryRun, convertWatchInterval = "", nil, false, time.Second
|
||||
if err := runConvertWatch(context.Background(), &bytes.Buffer{}, nil); err == nil {
|
||||
t.Error("expected error without --from-path/--from-list")
|
||||
}
|
||||
convertSourcePath, convertDryRun = "x.dbml", true
|
||||
if err := runConvertWatch(context.Background(), &bytes.Buffer{}, nil); err == nil {
|
||||
t.Error("expected error combining --watch with --dry-run")
|
||||
}
|
||||
}
|
||||
|
||||
func waitFor(t *testing.T, cond func() bool) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if cond() {
|
||||
return
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
t.Fatal("condition not met in time")
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
# Format Usage Examples
|
||||
|
||||
Examples for `relspec convert` covering the file-based reader and writer
|
||||
formats. The "Writers" and "Readers" sections below were run against
|
||||
`examples/test_schema.dbml`. The cross-format and live-database examples were
|
||||
not run; they follow the flags shown in `relspec convert --help` and require
|
||||
matching input files or reachable databases.
|
||||
|
||||
Any reader can be combined with any writer: pick `--from`/`--from-path` for the
|
||||
source and `--to`/`--to-path` for the target. Add `--silent` to suppress progress
|
||||
output.
|
||||
|
||||
## Writers: DBML to every format
|
||||
|
||||
```bash
|
||||
S="--from dbml --from-path examples/test_schema.dbml"
|
||||
|
||||
relspec convert $S --to json --to-path schema.json
|
||||
relspec convert $S --to yaml --to-path schema.yaml
|
||||
relspec convert $S --to dctx --to-path schema.dctx
|
||||
relspec convert $S --to drawdb --to-path schema.drawdb.json
|
||||
relspec convert $S --to graphql --to-path schema.graphql
|
||||
relspec convert $S --to prisma --to-path schema.prisma
|
||||
relspec convert $S --to pgsql --to-path schema.pg.sql
|
||||
relspec convert $S --to mssql --to-path schema.mssql.sql
|
||||
relspec convert $S --to sqlite --to-path schema.sqlite.sql
|
||||
relspec convert $S --to drizzle --to-path schema.ts
|
||||
relspec convert $S --to typeorm --to-path entities.ts
|
||||
relspec convert $S --to gorm --to-path models.go --package models
|
||||
relspec convert $S --to bun --to-path models.go --package models
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- Code-generation writers (`gorm`, `bun`) take `--package`. They also accept
|
||||
`--types baselib|stdlib|sqltypes` to choose the nullable type package.
|
||||
- When `--to-path` is a directory it must already exist.
|
||||
- `sqlite` output automatically flattens `schema.table` names. Use
|
||||
`--flatten-schema` for other formats if the target has no schema support.
|
||||
- `dctx` supports a single schema only; use `--schema <name>` to select one.
|
||||
|
||||
## Readers: file-based formats into DBML (or JSON where noted)
|
||||
|
||||
```bash
|
||||
relspec convert --from json --from-path schema.json --to dbml --to-path out.dbml
|
||||
relspec convert --from yaml --from-path schema.yaml --to dbml --to-path out.dbml
|
||||
relspec convert --from dctx --from-path schema.dctx --to dbml --to-path out.dbml
|
||||
relspec convert --from drawdb --from-path schema.drawdb.json --to dbml --to-path out.dbml
|
||||
relspec convert --from graphql --from-path schema.graphql --to dbml --to-path out.dbml
|
||||
relspec convert --from prisma --from-path schema.prisma --to dbml --to-path out.dbml
|
||||
relspec convert --from drizzle --from-path schema.ts --to dbml --to-path out.dbml
|
||||
relspec convert --from typeorm --from-path entities.ts --to dbml --to-path out.dbml
|
||||
relspec convert --from bun --from-path models.go --to dbml --to-path out.dbml
|
||||
relspec convert --from gorm --from-path models.go --to json --to-path out.json
|
||||
```
|
||||
|
||||
Code-first readers (`gorm`, `bun`, `drizzle`, `typeorm`) accept a single file or a
|
||||
directory of model files.
|
||||
|
||||
> Known issue: reading GORM models and writing DBML currently panics in the DBML
|
||||
> writer (`pkg/writers/dbml/writer.go`, `constraintToDBML`). Use another target
|
||||
> such as JSON until this is fixed.
|
||||
|
||||
## Cross-format combinations
|
||||
|
||||
```bash
|
||||
# ORM models to SQL DDL
|
||||
relspec convert --from gorm --from-path models.go --to pgsql --to-path schema.sql
|
||||
|
||||
# Prisma to Drizzle
|
||||
relspec convert --from prisma --from-path schema.prisma --to drizzle --to-path schema.ts
|
||||
|
||||
# DrawDB diagram to GraphQL
|
||||
relspec convert --from drawdb --from-path diagram.json --to graphql --to-path schema.graphql
|
||||
|
||||
# Merge several files while converting
|
||||
relspec convert --from json --from-list "a.json,b.json" --to yaml --to-path merged.yaml
|
||||
```
|
||||
|
||||
## Live databases
|
||||
|
||||
These need a reachable database:
|
||||
|
||||
```bash
|
||||
# PostgreSQL
|
||||
relspec convert --from pgsql --from-conn "postgres://user:pass@localhost:5432/mydb" \
|
||||
--to dbml --to-path schema.dbml
|
||||
|
||||
# SQL Server
|
||||
relspec convert --from mssql --from-conn "<mssql connection string>" \
|
||||
--to json --to-path schema.json
|
||||
|
||||
# SQLite database file (--from-conn takes the file path)
|
||||
relspec convert --from sqlite --from-conn ./app.db --to dbml --to-path schema.dbml
|
||||
```
|
||||
|
||||
## Formats outside `convert`
|
||||
|
||||
- `sqldir` (SQL script directory reader) and `sqlexec` (SQL execution writer) are
|
||||
used by `relspec scripts` and `relspec job`, and `sqldir` by `relspec diff`.
|
||||
See [SCRIPTS_COMMAND.md](SCRIPTS_COMMAND.md) and [JOB_FILES.md](JOB_FILES.md).
|
||||
- The `template` writer is exposed through `relspec templ`. See
|
||||
[TEMPLATE_MODE.md](TEMPLATE_MODE.md).
|
||||
+11
-2
@@ -187,12 +187,19 @@ jobs:
|
||||
format: pgsql
|
||||
path: build/schema.sql # file output, OR:
|
||||
conn_env: TARGET_DB_URL # execute against DB (pgsql only)
|
||||
# default: reads live DB, executes only the diff
|
||||
# (file output = full DDL); set options.full_ddl to force full DDL
|
||||
overwrite: false # default false
|
||||
options:
|
||||
strict_conversion: false # reject known lossy MySQL output before writing
|
||||
lowercase_identifiers: false # lowercase names/references; SQL expressions and data keep their case
|
||||
flatten_schema: false
|
||||
schema: public
|
||||
package: models # for gorm/bun output
|
||||
types: sqltypes # Bun/GORM nullable types: baselib|stdlib|sqltypes
|
||||
array_nullable: pointer_slice # Bun: slice|pointer_slice
|
||||
continue_on_error: false # pgsql / scripts-exec output
|
||||
full_ddl: false # pgsql database output: true = run full DDL; default diffs against live DB
|
||||
skip_relations: false # merge only
|
||||
skip_enums: false
|
||||
skip_views: false
|
||||
@@ -205,8 +212,10 @@ jobs:
|
||||
|
||||
For `templ`, `inputs` use the same file or `pgsql`/`conn_env` source forms as
|
||||
schema conversion. `output` is optional (empty means stdout); when present it
|
||||
contains only `path` and `overwrite`, because templates do not select a schema
|
||||
writer format.
|
||||
contains `path` and `overwrite`, plus the explicit `format: text` marker. The
|
||||
marker is templ-only: it describes arbitrary text produced by a Go template,
|
||||
not a database schema format. It is optional for compatibility with older
|
||||
manifests and has no effect on template execution.
|
||||
|
||||
A `from_job` input takes no `path`, `format` or `conn_env`: it resolves to the
|
||||
named job's `output.path` and inherits its format, and implies a dependency on
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
# MySQL schema fidelity
|
||||
|
||||
The MySQL reader/writer preserves the table-schema features below. It generates
|
||||
DDL for **new tables in existing databases**, not migrations or data copies.
|
||||
MySQL 8.4 / InnoDB is exercised by the live suite. The reader uses MySQL 8.0.23+
|
||||
metadata (including functional indexes, checks, spatial metadata, and invisible
|
||||
columns); older MySQL versions and the `mariadb` alias are not certified by this
|
||||
suite and may return explicit metadata-query errors.
|
||||
|
||||
## Conversion diagnostics
|
||||
|
||||
MySQL output warns on stderr when it encounters known unsupported features.
|
||||
Add `--strict-conversion` to reject these conversions before opening an output
|
||||
file or connecting to an output database:
|
||||
|
||||
```bash
|
||||
relspec convert --from mysql --from-conn "$MYSQL_DSN" \
|
||||
--to mysql --to-path schema.sql --strict-conversion
|
||||
|
||||
relspec convert --from json --from-path schema.json \
|
||||
--to mysql --to-path schema.sql --strict-conversion --dry-run
|
||||
```
|
||||
|
||||
The flag also applies to MySQL output from `batch`, `split`, and schema jobs.
|
||||
Jobs can set `options.strict_conversion: true`. Library callers use
|
||||
`writers.WriterOptions.StrictConversion`; `Warning func(string)` collects
|
||||
non-strict diagnostics instead of printing them. `mysql.Writer.Statements`
|
||||
performs the same preflight without opening a destination and returns structured
|
||||
SQL statements suitable for execution without splitting on semicolons.
|
||||
|
||||
Strict conversion currently covers **MySQL output**. It is not a general
|
||||
losslessness check for other writers or a validator of arbitrary SQL fragments.
|
||||
Warnings identify the object and omitted or changed feature. Inspect them before
|
||||
using non-strict output; unsupported index types are omitted, while a partial
|
||||
index's predicate and INCLUDE columns cannot be represented in MySQL.
|
||||
|
||||
## MySQL capability matrix
|
||||
|
||||
| Feature | Reader → model | Model → MySQL DDL |
|
||||
| --- | --- | --- |
|
||||
| Ordered columns, nullability, primary/composite keys | Preserved | Preserved |
|
||||
| Unique/composite keys | Preserved, including backing indexes | Preserved once; no duplicate unique index |
|
||||
| Foreign/composite keys and DELETE/UPDATE actions | Preserved | Added after all tables, allowing forward references/cycles |
|
||||
| CHECK and NOT ENFORCED CHECK | Preserved | Preserved |
|
||||
| BTREE, FULLTEXT, SPATIAL indexes | Preserved | Emitted; engine restrictions still apply |
|
||||
| Expression/prefix/descending/invisible index parts | Preserved in `Index.KeyParts` and metadata | Preserved |
|
||||
| HASH indexes | Preserved | Engine-dependent; incompatible engine produces a diagnostic |
|
||||
| Integer unsignedness; mediumint; enum/set; binary/blob/text variants | Canonical type plus native declaration | Native declaration preserved |
|
||||
| Length, numeric precision/scale, fractional seconds | Typed dimensions plus native declaration | Preserved; canonical dimensions also supported |
|
||||
| Defaults, including SQL expressions, bit/binary literals and multiline text | Values plus native SQL spelling from SHOW CREATE | Preserved |
|
||||
| Auto-increment and current next value | Preserved | Preserved |
|
||||
| Stored/virtual generated columns, ON UPDATE, invisible columns | Preserved | Preserved |
|
||||
| Engine, table/column collation, charset and comments | Preserved | Preserved |
|
||||
| Views, triggers, routines, events, partitioned tables | Names recorded in `mysql_unsupported` | Diagnostic; bodies/partition definitions are not emitted |
|
||||
| Nonempty table CREATE_OPTIONS; native parser/storage/engine attributes; spatial SRID | Recorded as unsupported attributes | Diagnostic; not emitted |
|
||||
| Partial/INCLUDE indexes, unsupported index methods | Retained when supplied in the model | Diagnostic |
|
||||
| Deferrable constraints, identity semantics, timestamptz, jsonb storage | Retained when supplied in the model | Diagnostic for unsupported/different semantics |
|
||||
| Standalone sequences/enums/domains, ownership/permissions, scripts and dialect directives | Retained when supplied in the model | Diagnostic; not emitted |
|
||||
| Data, grants, users, server/plugin configuration | Outside the reader's schema contract | Outside the writer's contract |
|
||||
|
||||
Diagnostics depend on the objects visible to the connection account. They cover
|
||||
known modeled/discovered features, not every engine/plugin extension. The server
|
||||
still validates SQL expressions, engine rules, and references at execution time.
|
||||
For example, source and target engines must support the constraints requested.
|
||||
|
||||
## Preservation across intermediate formats
|
||||
|
||||
| Path | Scope |
|
||||
| --- | --- |
|
||||
| MySQL → model → MySQL | Live-tested for the features above on MySQL 8.4 / InnoDB |
|
||||
| MySQL → JSON → model → MySQL | Live-tested; preserves typed index parts and MySQL metadata |
|
||||
| MySQL → YAML → model → MySQL | YAML serializes the same fields/metadata; covered by the live round-trip test |
|
||||
| Other source/target formats | Their own mappings apply; MySQL-specific metadata and semantics are not certified to survive |
|
||||
|
||||
Native declarations and defaults are stored in column metadata as `mysql_type`
|
||||
and `mysql_default_sql`. Reader-generated signatures (`mysql_type_signature`,
|
||||
`mysql_default_value`) prevent these spellings from silently overriding edited
|
||||
types/dimensions/defaults. Editing canonical dimensions invalidates the native
|
||||
type signature, produces a diagnostic, and uses the canonical mapping instead.
|
||||
For a binary default, the logical default is its SQL hex/literal spelling so it
|
||||
survives serialization without invalid UTF-8.
|
||||
|
||||
Other metadata includes `mysql_charset`, `mysql_generation_storage`,
|
||||
`mysql_default_expression`, `mysql_on_update`, `mysql_invisible`, table
|
||||
`mysql_engine` / `mysql_collation` / `mysql_auto_increment`, index
|
||||
`mysql_visible`, and check `mysql_enforced`. Unknown `mysql_*` metadata produces
|
||||
a diagnostic. For a manually authored expression default, set
|
||||
`mysql_default_expression: true`; otherwise strings are literal values.
|
||||
`Index.KeyParts` is authoritative when present; `Index.Columns` remains the
|
||||
compatibility representation for simple indexes.
|
||||
|
||||
Raw native type declarations, default SQL, generation/check/index expressions,
|
||||
and ON UPDATE expressions are executable SQL input. Lowercasing changes names
|
||||
and structured index parts but leaves expressions untouched; update references
|
||||
inside raw SQL yourself when using `--lowercase`.
|
||||
|
||||
## Applying the output
|
||||
|
||||
Create the destination database/schema first. Qualified names are preserved,
|
||||
including foreign-key target schemas. Adjust them explicitly when restoring to
|
||||
a different database. Output assumes standard MySQL backslash escaping;
|
||||
`NO_BACKSLASH_ESCAPES` or `ANSI_QUOTES` on the source produces a conversion
|
||||
diagnostic, and live execution refuses these target modes before DDL. File consumers must use the same
|
||||
supported mode. Other SQL-mode incompatibilities surface as server errors.
|
||||
|
||||
`CREATE TABLE IF NOT EXISTS` does not reconcile existing definitions. Foreign
|
||||
keys use `ALTER TABLE ADD CONSTRAINT` and therefore repeated application can
|
||||
fail on existing constraints. This is creation DDL, not a rerunnable migration.
|
||||
Live execution stops on the first SQL error by default. Explicit
|
||||
`ContinueOnError` attempts later statements and still returns an error if any
|
||||
fail. MySQL DDL implicitly commits; `AtomicExecution` is rejected. PostgreSQL's
|
||||
shared execution-report support is not yet implemented for this writer.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
./tests/dbtest/dbtest.sh test mysql
|
||||
# Or use your own disposable database:
|
||||
RELSPEC_TEST_MYSQL_CONN="$MYSQL_DSN" go test -mod=vendor ./pkg/readers/mysql ./pkg/writers/mysql
|
||||
```
|
||||
|
||||
The live tests compare MySQL's `SHOW CREATE TABLE` definitions, normalizing only
|
||||
temporary target-name suffixes and nonsemantic ordinary-index display order.
|
||||
They re-read the target, exercise constraint enforcement/default values, and
|
||||
verify unsupported objects produce warnings or strict failures without touching
|
||||
an existing output file. Test fixtures are removed after each run.
|
||||
|
||||
Metadata references: [COLUMNS](https://dev.mysql.com/doc/refman/8.4/en/information-schema-columns-table.html),
|
||||
[STATISTICS](https://dev.mysql.com/doc/refman/8.4/en/information-schema-statistics-table.html),
|
||||
[TABLE_CONSTRAINTS](https://dev.mysql.com/doc/refman/8.4/en/information-schema-table-constraints-table.html),
|
||||
and [REFERENTIAL_CONSTRAINTS](https://dev.mysql.com/doc/refman/8.4/en/information-schema-referential-constraints-table.html).
|
||||
+30
-2
@@ -96,11 +96,39 @@ INSERT INTO assets (message, payload)
|
||||
VALUES (:message, decode(:payload, 'base64')::bytea);
|
||||
```
|
||||
|
||||
- `path`: File path resolved relative to the SQL file containing the directive
|
||||
- `path`: Relative file path beneath the SQL file’s directory; traversal, absolute paths, and symlinks are rejected
|
||||
- `var`: Named placeholder to replace, such as `:message`
|
||||
- `mode`: `text` embeds an escaped SQL string literal; `base64` embeds a base64 string literal
|
||||
|
||||
The directive comment is removed from the SQL, and every matching placeholder is replaced before the script is listed or executed.
|
||||
A directive must be a standalone SQL line comment. Directive-like text inside
|
||||
quoted strings, quoted identifiers, block comments, or dollar-quoted bodies is
|
||||
ordinary SQL content. The directive is removed, and every matching placeholder in
|
||||
executable SQL is replaced once before listing or execution. Replacements are
|
||||
literal, preserving `$1`, `${2}`, `$HOME`, and other dollar text; inserted content
|
||||
is never scanned for another placeholder. Duplicate embed variables or variables
|
||||
used only inside strings/comments are errors. PostgreSQL casts (`::type`) remain
|
||||
unchanged. Asset-call named parameters follow these same lexical boundaries;
|
||||
repeated parameters share one positional argument.
|
||||
|
||||
Embed and manifest asset reads share root-scoped containment and a 64 MiB per-file
|
||||
limit. Only regular files are accepted. The SQL directory reader uses its selected
|
||||
script tree as an explicit asset root; embed paths remain relative to each script.
|
||||
Symlinks in asset paths are rejected, and containment is enforced again at open time.
|
||||
Manifest entries are checked at discovery and execution, so replacing a file with an
|
||||
escaping symlink after scanning cannot expose an external file. No per-directive
|
||||
external-file override is supported; copy required assets into the allowed tree.
|
||||
|
||||
Library callers can choose a trusted root and size limit with
|
||||
`assetloader.ProcessEmbedDirectivesWithOptions(..., assetloader.EmbedOptions{
|
||||
AssetRoot: root, MaxFileBytes: limit})`; zero means the 64 MiB default, and negative
|
||||
or unbounded limits are rejected. Manifest items retain `AssetRoot` and
|
||||
`MaxFileBytes` for execution-time validation.
|
||||
|
||||
For scripts loaded from a model rather than a SQL directory, the executor requires
|
||||
trusted writer configuration `Metadata["asset_root"]` before expanding directives.
|
||||
Model `source_path` metadata supplies resolution context only; it does not grant
|
||||
local-file access. Missing or out-of-root source paths fail before reading assets.
|
||||
|
||||
|
||||
## Commands
|
||||
|
||||
|
||||
@@ -570,3 +570,13 @@ COMMIT;
|
||||
- [RelSpec Documentation](../README.md)
|
||||
- [Model Structure Reference](../pkg/models/)
|
||||
- [Example Templates](../examples/templates/)
|
||||
|
||||
## Output path safety
|
||||
|
||||
In schema, domain, script, and table modes, `--output` always names a directory,
|
||||
including a nonexistent directory without a trailing slash. An empty value uses
|
||||
the current directory. Database mode uses a single output file as before.
|
||||
Generated paths must stay relative to that directory. Traversal, absolute or
|
||||
Windows-specific unsafe paths, symlinks, filename collisions (including case
|
||||
variants), and file/directory collisions are rejected before publishing artifacts.
|
||||
Nested patterns such as `{{.ParentSchema.Name}}/{{.Name}}.txt` remain supported.
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
# TUI mouse support plan
|
||||
|
||||
Issue: #46
|
||||
Status: design only; this document does not implement mouse input.
|
||||
|
||||
## 1. Current implementation and scope
|
||||
|
||||
The editor is created in `cmd/relspec/edit.go` by
|
||||
`ui.NewSchemaEditorWithConfigs(...).Run()`. `pkg/ui/editor.go` owns the
|
||||
`tview.Application`, `tview.Pages`, and application lifecycle. The current
|
||||
code never calls `Application.EnableMouse`, so tcell mouse reporting is off.
|
||||
The module uses tview v0.42.0 and tcell/v2 v2.13.9.
|
||||
|
||||
The first implementation should add `--no-mouse` to the `edit` Cobra command
|
||||
only. The flag is a local boolean, defaulting to false, and should be passed
|
||||
explicitly into the editor (prefer an options/config field rather than a
|
||||
package-global or environment variable). It must not affect convert, inspect,
|
||||
merge, or other commands. There is no environment-variable or persistent
|
||||
configuration setting in this issue: a command-line opt-out is predictable,
|
||||
visible in `edit --help`, and avoids adding configuration precedence rules.
|
||||
|
||||
At startup, the editor should call `app.EnableMouse(!noMouse)` before
|
||||
`Run()`. `--no-mouse` must mean that the application does not enable terminal
|
||||
mouse reporting and that no custom mouse handlers are relied upon. Keyboard
|
||||
behavior must remain identical in both modes.
|
||||
|
||||
Likely implementation files are `cmd/relspec/edit.go`,
|
||||
`pkg/ui/editor.go`, focused TUI mouse helpers/tests under `pkg/ui`, and a
|
||||
short user-facing note in the command help or TUI documentation. Do not
|
||||
refactor unrelated screens or data operations.
|
||||
|
||||
## 2. Widget and screen coverage
|
||||
|
||||
The application composes `Pages`, `Flex`, `TextView`, `List`, `Table`, `Form`,
|
||||
`Button`, `InputField`, `DropDown`, `TextArea`, `CheckBox`, and `Modal`.
|
||||
Vendored tview confirms mouse handlers exist for all of those relevant
|
||||
primitives, including focus on left-down, list/table selection, button clicks,
|
||||
form child dispatch, dropdown opening/drag selection, text-area cursor and
|
||||
scrolling, and modal button dispatch. `Pages`, `Flex`, and `Form` forward events
|
||||
to their children.
|
||||
|
||||
The coverage plan is:
|
||||
|
||||
* Main menu (`pkg/ui/main_menu.go`): left click focuses/selects a list entry;
|
||||
second activation opens it; buttons and exit confirmation remain reachable.
|
||||
* Schema, table, domain, object, relation, and database screens: click a row
|
||||
to select it; double-click the row to perform the same action as the
|
||||
keyboard Enter/selected callback where opening is meaningful; scroll lists
|
||||
and tables; click each action button.
|
||||
* Tables (`schema_screens.go`, `table_screens.go`, and object/relation tables):
|
||||
tview's table handler provides selection and scrolling, but it does not
|
||||
provide application-specific double-click activation. Add a small reusable
|
||||
wrapper/helper for the table instances that need it. It must preserve the
|
||||
existing selected row/column behavior and invoke the same callback as Enter,
|
||||
not duplicate mutation logic.
|
||||
* Forms (`load_save_screens.go` and the form-building screen files): click an
|
||||
input to focus it, click buttons to activate them, click a dropdown to open
|
||||
it and choose an option, scroll multiline help/text areas, and retain all
|
||||
existing keyboard Tab/Shift-Tab, shortcut, Enter, and Escape behavior.
|
||||
* Dialogs (`pkg/ui/dialogs.go` plus confirmation/error/success modals): modal
|
||||
buttons are clickable and the modal keeps focus above the underlying page.
|
||||
Clicking outside a modal must not activate the hidden page or dismiss a
|
||||
destructive confirmation. Escape and the existing button-key behavior stay
|
||||
authoritative.
|
||||
* The planned file browser and connection-string builder from issue #44 must
|
||||
use the same contracts: clickable entries/buttons and scrolling, with
|
||||
keyboard navigation and explicit cancel/accept paths. #46 should not
|
||||
implement #44's widgets; it should define the integration point and test
|
||||
them when #44 lands.
|
||||
|
||||
Do not promise drag semantics for every widget. Drag is appropriate for text
|
||||
selection/cursor movement and dropdown selection where tview already supports
|
||||
it. For ordinary list/table navigation, a click selects and the wheel scrolls;
|
||||
row dragging should not mutate data.
|
||||
|
||||
## 3. Exact mouse action contract
|
||||
|
||||
| Widget/type | Left down/click | Double click | Wheel/drag | Keyboard fallback |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Main/list menu | focus and select row | invoke row selected callback | scroll list | arrows, Enter, shortcuts |
|
||||
| Data table | focus and select cell/row | invoke the screen's existing open/edit action for the selected row | vertical/horizontal scroll as supported by tview | arrows, PageUp/PageDown, Enter, existing shortcuts |
|
||||
| Button | focus | same as one activation, never duplicate the callback | none | Tab/Shift-Tab, Enter/Space and existing shortcut |
|
||||
| Input field | focus; place cursor if supported | no destructive action | text-area behavior if provided by tview | typing, arrows, Home/End, Tab, Escape |
|
||||
| Text area/help | focus and position cursor | select word only where tview supports it; no application action | scroll; drag text selection if supported | arrows, PageUp/PageDown, standard editing keys |
|
||||
| Dropdown | focus/open and choose the hit option | same as click; no duplicate selection | drag through options only while open | arrows, Enter, Escape, Tab |
|
||||
| Checkbox | toggle on click | no second toggle | none | Space and existing form navigation |
|
||||
| Modal | focus/click visible button | same button action once | no underlying-page scrolling | Tab/Shift-Tab, Enter, Escape, existing button keys |
|
||||
| Blank/border/title area | focus containing primitive where useful | none | no mutation | current screen shortcuts |
|
||||
|
||||
Right and middle clicks should have no application action in the first
|
||||
release. Wheel events should be consumed only by the scrollable primitive
|
||||
under the pointer. Double-click timing/translation should come from tview/
|
||||
tcell; custom code must not fire the action once for both the click and the
|
||||
double-click. Any custom table wrapper needs a small state machine or tview
|
||||
mouse action handling that is tested for this property.
|
||||
|
||||
## 4. tview gaps and implementation boundaries
|
||||
|
||||
Enabling mouse support is not sufficient for the desired behavior. tview's
|
||||
built-in Table handler selects cells and scrolls but has no repository-specific
|
||||
row-open callback on double click. Existing screen code also wires keyboard
|
||||
input captures directly on individual widgets, so mouse actions must call the
|
||||
same screen callbacks rather than route through synthetic key events.
|
||||
|
||||
Use tview's `MouseHandler`/`WrapMouseHandler` contracts and `setFocus` rather
|
||||
than reading terminal coordinates in each screen. A reusable table adapter
|
||||
may embed `*tview.Table`, delegate ordinary actions to the original table
|
||||
handler, and add the screen's double-click callback. Keep the adapter in
|
||||
`pkg/ui` and use it only where a row-opening action exists. Do not modify the
|
||||
vendored tview copy.
|
||||
|
||||
The `Pages`/`Modal` dispatch order must be verified: a visible modal consumes
|
||||
its click before the page below it. Page transitions should happen only in the
|
||||
existing callbacks, so a stale hidden page cannot receive a click.
|
||||
|
||||
## 5. Keyboard, terminal, and copy/paste behavior
|
||||
|
||||
Mouse is an enhancement, never a requirement. Every acceptance path must be
|
||||
reachable with the existing keyboard controls, including load/save, navigation,
|
||||
editing, confirmations, cancel, and exit. `--no-mouse` is the regression mode
|
||||
for proving this contract.
|
||||
|
||||
Mouse reporting is terminal capability dependent. On local terminals it is
|
||||
negotiated by tcell; tmux and SSH can suppress, translate, or fail to pass
|
||||
mouse reporting depending on their configuration. The application must still
|
||||
start and remain keyboard usable if mouse reporting is unavailable or broken.
|
||||
Documentation should state that terminal/tmux configuration may be required,
|
||||
and that SSH behavior depends on the remote terminal path. Windows Terminal and
|
||||
other Windows console hosts should be treated as supported only insofar as the
|
||||
selected tcell backend reports mouse events; the CLI must not assume POSIX
|
||||
escape sequences or add platform-specific code in this issue.
|
||||
|
||||
Enabling mouse capture normally prevents terminal-native selection/copy from
|
||||
seeing ordinary button-drag events. Document the standard workaround: hold the
|
||||
terminal's bypass modifier (commonly Shift, terminal-dependent) for selection,
|
||||
or use `--no-mouse` when native copy/paste is the priority. Do not implement a
|
||||
second clipboard protocol. Input-field/text-area copy/paste must continue to
|
||||
use tview/tcell paste handling and keyboard shortcuts; verify that enabling
|
||||
mouse does not intercept paste events.
|
||||
|
||||
## 6. Test strategy using tcell simulation
|
||||
|
||||
Add focused tests rather than attempting a full interactive end-to-end test.
|
||||
Use `tcell.NewSimulationScreen("")`, `screen.Init()`, construct the editor or
|
||||
an isolated primitive tree, and inject events with the actual API:
|
||||
`SimulationScreen.InjectMouse(x, y, buttons, mod)` and `InjectKey(...)`.
|
||||
Coordinates must be derived from the primitive's drawn rectangle or fixed by a
|
||||
small deterministic test layout; do not use arbitrary coordinates without
|
||||
checking the rendered screen.
|
||||
|
||||
Minimum cases:
|
||||
|
||||
1. Default editor configuration enables mouse; the explicit disabled option
|
||||
leaves it disabled. If the Application API is not observable directly,
|
||||
test through the simulation screen's event path plus a constructor-level
|
||||
option assertion.
|
||||
2. A list click changes focus/selection, and double-click invokes the existing
|
||||
selected action exactly once.
|
||||
3. A table click selects the expected row/cell, wheel events change the visible
|
||||
offset, and double-click invokes the row action exactly once.
|
||||
4. Form button, input field, checkbox, and dropdown clicks match their
|
||||
keyboard callbacks.
|
||||
5. A modal button click acts on the modal and cannot activate the underlying
|
||||
page; Escape still cancels.
|
||||
6. `--no-mouse` leaves keyboard selection/activation unchanged and mouse
|
||||
injection has no application effect.
|
||||
7. Existing dialogs and screen transitions do not leave a stale mouse capture
|
||||
after a page is removed.
|
||||
|
||||
Prefer callback counters and selected-index assertions over screen-text-only
|
||||
assertions. Run the relevant `pkg/ui` tests with `go test -race ./pkg/ui` and
|
||||
run the full repository test suite if time/resources permit.
|
||||
|
||||
## 7. Rollout and acceptance criteria
|
||||
|
||||
Implementation is ready for review when:
|
||||
|
||||
* `relspec edit --help` documents `--no-mouse` and mouse is enabled by default.
|
||||
* Only the edit TUI is affected; non-TUI commands have no changed behavior.
|
||||
* Main screens, tables, lists, forms, dropdowns, buttons, text areas, and
|
||||
visible dialogs support the action contract above.
|
||||
* Keyboard-only operation is complete and verified with `--no-mouse`.
|
||||
* Modal clicks cannot fall through to an underlying page.
|
||||
* Table double-click behavior is explicit, tested, and does not duplicate
|
||||
activation.
|
||||
* tcell simulation tests cover default-on, opt-out, selection, scrolling,
|
||||
activation, dialog focus, and keyboard fallback.
|
||||
* `go test -race ./pkg/ui`, appropriate command tests, `go test ./...`,
|
||||
formatting, and `git diff --check` pass (or any limitation is recorded).
|
||||
* User-facing docs explain tmux/SSH/Windows variability and the terminal
|
||||
modifier workaround for native copy/paste.
|
||||
|
||||
Roll out in two implementation slices if needed: first application option,
|
||||
standard tview handlers, tests, and documentation; second only the reusable
|
||||
table double-click adapter and screen wiring. Do not block the first slice on
|
||||
issue #44, but do not claim #44's future widgets are covered until they use the
|
||||
same contract.
|
||||
|
||||
## 8. Open decisions and dependencies
|
||||
|
||||
* Confirm whether the project wants a public editor options type or a small
|
||||
`SetMouseEnabled`/constructor parameter; avoid a global flag.
|
||||
* Confirm the preferred terminal copy modifier in project documentation, since
|
||||
tmux, SSH clients, and Windows Terminal differ.
|
||||
* Decide whether horizontal wheel events should be supported where tview/table
|
||||
exposes them; vertical scrolling is mandatory, horizontal is optional.
|
||||
* Decide whether double-click opens every data table or only tables with an
|
||||
unambiguous row action. The plan recommends the latter.
|
||||
* Confirm #44's file-browser and connection-builder primitive choices before
|
||||
wiring their mouse tests.
|
||||
* Confirm CI has a stable non-terminal environment for simulation-screen tests;
|
||||
no real terminal, tmux session, database, or network should be required.
|
||||
+359
@@ -0,0 +1,359 @@
|
||||
# Project audit 1
|
||||
|
||||
Date: 2026-10-12
|
||||
Revision reviewed: `bfe98528265b45be7bfdcdf025710981bae06a7b`
|
||||
Perspectives: software engineering, security, maintenance, and databases.
|
||||
|
||||
## Repair progress
|
||||
|
||||
Implementation batches (2026-10-12):
|
||||
|
||||
- [x] **A01, PostgreSQL failure propagation:** stop on the first SQL failure by default; explicit continuation still returns a structured `ExecutionFailure`. Reports include skipped entries, and requested report write/close failures are errors. Live tests cover a middle-statement failure in both modes and verify dependent jobs do not start.
|
||||
- [x] **A02, implicit replacement removed:** both full-DDL and diff type changes propagate conversion errors without renaming/replacing the column. Live populated-data tests verify original values, column count/type, unique-index identity, and constraint enforcement survive incompatible conversions.
|
||||
- [x] **Related defect exposed by A01:** full-DDL column additions now use native `ADD COLUMN IF NOT EXISTS`, fixing duplicate-column errors for flattened output that previously appeared successful.
|
||||
- [x] **A01, shared execution results:** PostgreSQL, SQLite, SQL Server, and raw script execution now use `writers.ExecutionReport` / `ExecutionFailure`. Stop/continue modes always fail on SQL errors; counters/results survive early failure and aggregate across script schemas. Reports distinguish applied, failed, skipped, rolled-back, and uncertain operations. Generated DDL supports optional atomic execution; raw scripts report script-managed outcomes without promising statement-level commit/rollback. Requested report failures are errors in every executor. Live PostgreSQL, SQL Server, SQLite, and dependent-job regressions pass.
|
||||
- [x] **A02, conversion expressions and preflight:** column metadata `pgsql_using` configures an explicit SQL conversion expression; `pgsql_preflight` adds a row predicate. Full-DDL and diff plans evaluate every existing value before schema mutations, and live preflight failure stops even with continuation enabled. Tests cover later invalid rows, preservation of a constraint scheduled for removal, custom conversions, predicate rejection, invalid configuration, and block-delimiter collisions. Standalone schema generation also includes preflights.
|
||||
|
||||
- [x] **A03, contained template output:** multi-file output has an explicit directory root, portable relative filename validation, and preflight collision/symlink checks. Rendering/validation complete before writing; exclusive per-file staging and root-scoped rename prevent generated filenames from escaping the root. Multi-file atomic publication remains A16 work.
|
||||
- [x] **A04, contained asset reads:** embed directives and manifest execution use shared root-scoped reads, symlink checks, regular-file validation, and a bounded 64 MiB default. Manifest paths are rechecked at execution. Imported source-path metadata cannot authorize file reads without a trusted configured asset root. Library callers can explicitly configure a root/size limit; directives cannot opt out of containment.
|
||||
- [x] **A05, PostgreSQL quoting:** schema/full-DDL/diff paths and embedded templates always quote model identifiers, quote literal/catalog/regclass values, sanitize names in generated line comments, and choose collision-safe dollar delimiters for procedural and audit function bodies. NUL is rejected before output; migration comparisons preserve identifier case. Raw SQL expressions remain an explicit executable-input boundary.
|
||||
- [x] **Requested lowercase option:** `--lowercase` on convert/batch/merge/split/templ/job run, job `options.lowercase_identifiers`, and PostgreSQL `WriterOptions.LowercaseIdentifiers` normalize names and structured references on a copy, rejecting collisions. SQL expressions, defaults, comments, enum values, and data keep their case; callers must adjust references inside raw SQL themselves.
|
||||
- [x] **A08, SQL-aware substitution/execution:** embeds use one-pass literal substitution, preserving dollar characters and preventing replacement rescanning. Named parameters and embed directives respect SQL strings, quoted identifiers, nested comments, casts, and dollar bodies; duplicate/unused embed variables fail. MySQL executes structured generated statements without splitting multiline literals or stripping their contents.
|
||||
|
||||
- [x] **A06, nullable MySQL catalogs:** constraint references, functional-index columns/expressions, index prefix/direction fields, column dimensions/collations/generation metadata, and optional table auto-increment counters use nullable destinations. Queries qualify joined columns. Live primary/unique/composite/functional-key reads pass, including unsigned auto-increment values beyond signed 64-bit range.
|
||||
- [x] **A07, scoped MySQL fidelity and diagnostics:** native types/default spellings and typed dimensions/index parts survive MySQL → model → JSON/YAML → DDL. Output emits foreign keys/actions after all tables, checks/enforcement, unique/ordinary/functional/prefix/descending/invisible/fulltext indexes, generated columns, comments/collations/charsets, ON UPDATE, invisible columns, and auto-increment counters. Known unsupported objects/attributes produce warnings or `--strict-conversion` / job `options.strict_conversion` / library `StrictConversion` failures before output or connection. See [the scoped capability matrix](MYSQL_FIDELITY.md); this covers new-table DDL on MySQL 8.4 / InnoDB, not all engines, MariaDB versions, other format mappings, or migrations.
|
||||
|
||||
A06–A07 verification (2026-10-12): live MySQL 8.4.11 tests compare source/target `SHOW CREATE TABLE` definitions (normalizing temporary target suffixes and ordinary-index display order), re-read the target, and verify FK/check enforcement and binary/bit/multiline/backslash/default values. The suite covers composite/functional unique keys, reverse table dependency order, generated/check/index expressions containing SQL literals, precision, unsigned ranges, and multiple FKs to the same target. It exposed and fixed truncated binary defaults in `information_schema` by extracting native defaults from SQL-aware `SHOW CREATE TABLE` definitions. Native SHOW CREATE also avoids stale auto-increment statistics, and escaped catalog expressions are decoded before rendering. Unsupported views/triggers/partitioned tables produce warnings and strict failures; strict failure preserves existing files and happens before opening an output connection. CLI dry-run and per-job strict options are tested. The MySQL container test runner now selects reader/writer packages by default. `go test -mod=vendor -p 1 ./...` passed with `RELSPEC_TEST_MYSQL_CONN` configured; `go vet -mod=vendor ./...`, CLI strict/dry-run checks, shell syntax, and `git diff --check` passed. The MySQL container runner also passed using its schema-scoped account; its disposable server enables trigger-fixture creation after final-server readiness. PostgreSQL and SQL Server live variables were unset for this batch. Checks used `go1.27.1-X:nodwarf5 linux/amd64`; declared-minimum-toolchain coverage remains A13 work. Disposable MySQL instances were removed after verification. A08 now also has live MySQL coverage for embedded statement delimiters/default contents.
|
||||
|
||||
A05–A08 verification: unit regressions and live PostgreSQL 18 checks cover unusual names, comments/defaults, rerunnable file/statement output, diff migrations, sequences, primary-key replacement, audit functions/triggers, and both standard-string modes. MySQL statement preservation is unit-tested; a live MySQL instance was not used for A08. `go test -mod=vendor -p 1 ./...` passed with `RELSPEC_TEST_PG_CONN` set to the disposable instance; `go vet -mod=vendor ./...`, executable CLI help checks, and `git diff --check` passed. SQL Server tests were not configured for this batch. The disposable instance was removed after verification.
|
||||
|
||||
Additional observation: a parallel live full-suite run hit a PostgreSQL reader catalog race (`could not open relation with OID`) while another package's test dropped schema objects. The writer safely fell back to full DDL and its preflight still stopped all mutations, but the existing test assumed no successful read-only preflights before failure. Running live packages serially passed. Reader resilience to concurrent catalog changes remains outside A05/A08; A10 specifically concerns cancellation/timeouts.
|
||||
|
||||
A03–A04 verification: `go test -mod=vendor ./...`, targeted regression tests, `go vet -mod=vendor ./...`, and `git diff --check` passed. Database-dependent tests were not configured for this filesystem-only batch. Windows test binaries were cross-compiled for the template and asset packages; Windows runtime filesystem tests remain unperformed.
|
||||
|
||||
Verification: `go test -mod=vendor ./...` passed with `RELSPEC_TEST_PG_CONN` and `RELSPEC_TEST_MSSQL_CONN` pointing to disposable PostgreSQL 18 and SQL Server 2022 Developer instances; SQLite regressions use temporary database files. A minimal public table was seeded for the existing PostgreSQL `ReadTable` test, which requires a nonempty default schema. `go vet -mod=vendor ./...` and `git diff --check` passed. Executable CLI checks verified `scripts execute --ignore-errors`, `--continue-on-error`, and `--coe` all attempt a later script after failure and exit 1. Raw script tests intentionally make no statement-level rollback claim. A09 remains open for concurrency, history, and migration recovery; sequence/external side effects are outside transaction rollback guarantees. Original audit evidence is retained for historical context.
|
||||
|
||||
## Assessment
|
||||
|
||||
RelSpec has a useful architecture, broad format support, and substantial tests. Its most serious weaknesses are in the boundary between generating a schema and safely applying that schema to a real database. A successful process exit does not reliably mean a successful migration. Some conversions silently discard database semantics, and several file/SQL generation paths do not preserve the safety guarantees implemented elsewhere.
|
||||
|
||||
**Recommendation: fix the high-priority findings before relying on unattended production database writes or processing externally supplied specifications.** Continue using generated output with review and disposable database validation while these issues are addressed. This is a repository audit, not a certification of production readiness.
|
||||
|
||||
### Existing strengths
|
||||
|
||||
- Readers, writers, the intermediate model, diffing, inspection, and transformations are separated into packages.
|
||||
- There are many unit and regression tests, including live PostgreSQL writer tests and SQLite tests.
|
||||
- Job files have semantic validation, dependency-cycle detection, environment-based connection references, path containment checks, and symlink checks.
|
||||
- Single-file job conversions already use temporary output followed by rename.
|
||||
- The asset manifest executor binds file bytes as database parameters.
|
||||
- The repository includes a private vulnerability reporting policy, vendored dependencies, and release-time vet/static analysis/vulnerability-check steps.
|
||||
|
||||
These controls should be extended and made consistent, rather than replaced wholesale.
|
||||
|
||||
## Scope and verification
|
||||
|
||||
Reviewed the CLI, job handling, database readers/writers, PostgreSQL templates and migration generation, asset embedding, generic templates, connection builder, updater, build/release workflows, documentation, and the VS Code extension. Review depth was greatest for database mutation and input/output trust boundaries; every parser and format combination was not exhaustively audited.
|
||||
|
||||
Checks performed:
|
||||
|
||||
| Check | Result |
|
||||
| --- | --- |
|
||||
| `GOCACHE=/tmp/relspec-audit-gocache go test -mod=vendor ./...` | Initial sandbox run failed because HTTP tests could not open loopback sockets. Approved rerun outside the sandbox passed. |
|
||||
| `GOCACHE=/tmp/relspec-audit-gocache go vet -mod=vendor ./...` | Passed. |
|
||||
| `make fmt-check GOLANGCI_LINT=false` | Incorrectly passed, confirming that a failed formatter can be hidden. |
|
||||
| Temporary Go reproduction using exported package APIs | Confirmed template path escape, embed path escape, embed dollar corruption, invalid PostgreSQL identifier/literal rendering, omitted MySQL FK/index output, and placeholder matching inside SQL strings. |
|
||||
|
||||
The local compiler reported `go1.27.1-X:nodwarf5 linux/amd64`; `go.mod` requires `1.26.9`. This was not a verification under the declared minimum toolchain. No live PostgreSQL, MySQL, or SQL Server connection was configured for the audit. Tests requiring those connections skipped; a passing package does not demonstrate live database correctness. Race testing, fuzz campaigns, benchmarks, `staticcheck`, and a fresh `govulncheck` scan were not performed. No claims about specific dependency CVEs are made.
|
||||
|
||||
Reproduction files and logs were placed in `/tmp`; the only intended repository change is this document. No real credentials or user data were used in reproductions. Database injection and migration effects were assessed from code/generated SQL, not executed against a database.
|
||||
|
||||
### Priority and evidence notation
|
||||
|
||||
- **P1 / High:** address before unattended database use or handling less-trusted inputs; credible data-integrity, confidentiality, or integrity impact.
|
||||
- **P2 / Medium:** schedule in the next maintenance cycle; operational reliability, reproducibility, or defense-in-depth issue.
|
||||
- **P3 / Low:** documentation or developer-experience issue.
|
||||
- **Reproduced:** a local executable check demonstrated the stated behavior.
|
||||
- **Code-confirmed:** the implementation establishes the behavior, but the complete external-system scenario was not executed.
|
||||
|
||||
## Findings
|
||||
|
||||
### A01 — PostgreSQL writes report success after statement failures
|
||||
|
||||
**Repair status: addressed.** The shared executor now enforces stop/continue behavior and nonzero failure across PostgreSQL, SQLite, SQL Server, and raw scripts, including requested report errors. Reports include individual outcomes, skipped operations, confirmed rollbacks, and uncertain finalization. Atomic generated-DDL runs are optional; raw scripts explicitly report script-managed execution. A09 remains a separate migration-management/concurrency contract.
|
||||
|
||||
**P1 / High · Engineering, database · Code-confirmed**
|
||||
|
||||
Evidence: `pkg/writers/pgsql/writer.go:2200–2322`, especially `executeStatements` and `finishReport`; `pkg/writers/pgsql/live_execute_test.go:144` explicitly tests continuing after failure.
|
||||
|
||||
Every failed statement is recorded and execution continues. `finishReport` prints an error count but returns `nil`. Failure to write the requested report is also only a warning. This behavior is unconditional in this execution path. A deployment or dependent job can proceed after an incomplete schema change because the writer returned success.
|
||||
|
||||
**Fix:** return a structured error when any statement fails; stop on the first error by default. A continue-on-error option should control continuation, separately from the final exit status. Aggregate results consistently across PostgreSQL, SQLite, SQL Server, and script execution. Treat failure to produce an explicitly requested execution report as an error.
|
||||
|
||||
**Acceptance:** force a middle statement to fail; the CLI exits nonzero, dependent jobs do not run, and the report distinguishes applied, failed, skipped, and rolled-back operations. Test both stop and continue modes.
|
||||
|
||||
### A02 — Failed type conversion silently substitutes an empty column
|
||||
|
||||
**Repair status: addressed.** The shared type-change template propagates conversion errors. Configurable `pgsql_using` expressions and `pgsql_preflight` predicates are supported, with automatic whole-column cast evaluation before schema mutations. Full-DDL and diff live tests verify preservation of populated columns and unique dependencies, custom conversions, and rejected predicates. Preflight is advisory under concurrent writes; atomic generated-DDL execution is available for transactionally supported changes, and separately reviewed rename/replacement remains required.
|
||||
|
||||
**P1 / High · Database · Code-confirmed**
|
||||
|
||||
Evidence: `pkg/writers/pgsql/templates/alter_column_type_with_check.tmpl:18–27`; `pkg/writers/pgsql/migration_writer.go:491`; `pkg/writers/pgsql/writer.go:514`.
|
||||
|
||||
The type-change block catches `WHEN OTHERS`, renames the existing column, and adds a fresh column with the original name. For example, converting populated text values containing non-numeric data to an integer can fail the cast and activate this fallback. The application then sees an empty replacement column. Existing indexes, constraints, and dependencies may remain attached to the renamed column. Original values are not necessarily deleted, but the application's expected column no longer contains them.
|
||||
|
||||
The broad exception handler also treats unrelated failures as permission to attempt this schema rewrite.
|
||||
|
||||
**Fix:** fail on conversion errors. Require a separately reviewed migration for any rename-and-replace operation, including backfill, validation, dependency handling, and recovery. Support an explicit conversion expression and preflight checks where appropriate.
|
||||
|
||||
**Acceptance:** migrating populated incompatible data leaves the original column, values, and dependencies intact and returns an actionable failure. No rename/replacement occurs implicitly.
|
||||
|
||||
### A03 — Generated template filenames escape the output directory
|
||||
|
||||
**Repair status: addressed.** Multi-file roots, generated paths, collisions, symlinks, and root-scoped writes are covered by regression tests. A nonexistent output path now consistently denotes the directory itself rather than its parent.
|
||||
|
||||
**P1 / High · Security · Reproduced**
|
||||
|
||||
Evidence: `pkg/writers/template/writer.go:249–308`.
|
||||
|
||||
The default filename pattern uses `{{.Name}}.txt`. `generateFilename` joins the rendered name with the destination without checking containment; `writeOutput` creates directories and writes the resulting path. A schema named `../escaped` wrote `escaped.txt` beside the selected output directory in the reproduction.
|
||||
|
||||
An imported model can therefore cause writes outside the intended destination, including overwriting files writable by the current user. Job-level checks validate the declared output path, not every subsequently generated filename. Exploitation requires the user to process the supplied model in a multi-file template mode.
|
||||
|
||||
**Fix:** validate every rendered filename against a clearly defined output root; reject traversal, unsafe absolute paths, and symlink escapes. Detect filename collisions before writing. Prefer root-scoped filesystem operations so validation and opening cannot be separated by a symlink replacement.
|
||||
|
||||
**Acceptance:** malicious names, filename patterns, symlink parents, duplicate names, and platform-specific paths cannot write outside the root or silently overwrite another generated artifact.
|
||||
|
||||
### A04 — SQL embed directives can read outside the script directory
|
||||
|
||||
**Repair status: addressed.** Shared bounded root-scoped reads protect directives and manifest assets, including revalidation after discovery. Model source-path metadata alone no longer grants local file access.
|
||||
|
||||
**P1 / High · Security · Reproduced**
|
||||
|
||||
Evidence: `pkg/assetloader/embed.go:73–74`; compare the containment checks in `pkg/assetloader/loader.go` and `pkg/jobs/jobs.go`.
|
||||
|
||||
Embed paths are cleaned and joined to the script directory, then passed directly to `os.ReadFile`. A directive using `path=../synthetic-secret.txt` successfully embedded a sibling file. There is no equivalent root or symlink policy for this path. Script metadata also supplies the source path used for resolution.
|
||||
|
||||
A supplied script can cause local readable files to be copied into generated SQL or sent to the destination database. Permission to execute SQL against a database should not implicitly grant unrestricted local-file access. This is a local input trust-boundary issue, not an unauthenticated network endpoint.
|
||||
|
||||
**Fix:** define an explicit asset root, contain all embed reads within it, check symlink resolution, limit file size, and document any opt-in allowance for external files. Reuse the policy across manifest assets and embed directives.
|
||||
|
||||
**Acceptance:** traversal and symlink escape fail before reading; valid in-root text and binary assets still work; diagnostics do not print file contents.
|
||||
|
||||
### A05 — PostgreSQL SQL quoting is incomplete across syntactic contexts
|
||||
|
||||
**P1 / High · Security, database · Reproduced rendering defects**
|
||||
|
||||
Evidence: `pkg/writers/pgsql/template_functions.go:134`; `pkg/writers/pgsql/templates/add_column_with_check.tmpl:5–7`; `create_foreign_key_with_check.tmpl`; `alter_column_type_with_check.tmpl`.
|
||||
|
||||
Two separate defects were confirmed:
|
||||
|
||||
1. `quoteIdent` does not actually check reserved keywords, despite its comment. The template executor generated `CREATE TABLE IF NOT EXISTS public.order (select text);` for legal names requiring quoting.
|
||||
2. Catalog lookups insert names directly into single-quoted SQL strings. A table named `O'Reilly` generated `table_name = 'O'Reilly'`. Quoting a name for an identifier elsewhere does not escape it as a string literal here.
|
||||
|
||||
Fixed `DO $$ ... $$` delimiters create another context that must be protected from embedded delimiter text. Besides breaking valid database names, insufficient context-aware quoting is an injection risk when less-trusted metadata is rendered and executed using privileged database credentials. A complete injection exploit was not executed during this audit. Raw SQL expressions are a separate, intentional executable-input boundary and should be documented as such.
|
||||
|
||||
**Fix:** centralize identifier, literal, qualified-name, and block-delimiter handling. Always quote identifiers where practical; parameterize values where supported. Generate collision-safe dollar delimiters or avoid interpolating arbitrary content into fixed blocks. Apply this across all templates, not only the examples above.
|
||||
|
||||
**Acceptance:** generated SQL executes correctly for reserved words, apostrophes, double quotes, Unicode, newlines, and dollar delimiters, and cannot introduce additional SQL statements through a name.
|
||||
|
||||
PostgreSQL documents the distinct identifier, string, and dollar-quote rules in [Lexical Structure](https://www.postgresql.org/docs/current/sql-syntax-lexical.html).
|
||||
|
||||
### A06 — MySQL constraint reading scans nullable metadata into strings
|
||||
|
||||
**Repair status: addressed.** Nullable metadata scans and qualified queries are covered by live MySQL 8.4 round trips with primary, unique, composite FK, and functional-index definitions.
|
||||
|
||||
**P1 / High · Database · Code-confirmed; live MySQL not exercised**
|
||||
|
||||
Evidence: `pkg/readers/mysql/reader.go:181–220`, specifically the `rs`, `rt`, and `rc` string variables passed to `rows.Scan`.
|
||||
|
||||
The constraint query reads `REFERENCED_TABLE_SCHEMA`, `REFERENCED_TABLE_NAME`, and `REFERENCED_COLUMN_NAME` for primary and unique constraints as well as foreign keys. Non-foreign-key rows have no referenced object and can contain SQL NULL. Scanning these into Go strings fails, aborting inspection of ordinary tables with a primary key.
|
||||
|
||||
**Fix:** use nullable destinations such as `sql.NullString`, or explicit `COALESCE` where an empty string has the intended meaning. Review other nullable metadata, including expression-index column names, in the same reader.
|
||||
|
||||
**Acceptance:** live MySQL tests read tables with primary, unique, composite foreign-key, and expression-index definitions without scan failures and preserve their metadata. The current reader tests only cover type mapping and a missing connection string.
|
||||
|
||||
Reference: [MySQL KEY_COLUMN_USAGE documentation](https://dev.mysql.com/doc/refman/8.0/en/information-schema-key-column-usage-table.html) and [MySQL's documented non-FK NULL behavior](https://bugs.mysql.com/bug.php?id=9587).
|
||||
|
||||
### A07 — MySQL conversion silently loses constraints, indexes, and type detail
|
||||
|
||||
**Repair status: addressed within the [documented MySQL table-schema contract](MYSQL_FIDELITY.md).** Supported features survive live round trips through JSON/YAML; known unsupported modeled/discovered features produce diagnostics or strict conversion failures. MySQL output remains creation DDL for existing databases, with engine/version/SQL-mode limits stated in the capability matrix.
|
||||
|
||||
**P1 / High · Database · Reproduced omissions; additional losses code-confirmed**
|
||||
|
||||
Evidence: `pkg/writers/mysql/writer.go:96–163`; `pkg/readers/mysql/reader.go:153–178`; `pkg/mariadb/datatypes.go`.
|
||||
|
||||
The writer emits columns, primary keys, and unique constraints, but does not emit foreign keys, checks, or ordinary indexes from the model. A reproduction containing both a foreign key and an index returned success while emitting neither. The reader maps `COLUMN_TYPE` to a canonical base type without populating the separate length/precision/scale fields; its tests explicitly map `bigint unsigned` to `int64`.
|
||||
|
||||
Consequences include lost referential integrity, missing performance-critical indexes, changed ranges, and changed string/decimal definitions. Advertising a format as supported does not communicate these losses.
|
||||
|
||||
**Fix:** preserve and emit supported semantics; return explicit diagnostics for every unsupported object or attribute. Add a strict conversion mode that refuses lossy output and a per-format capability matrix. Preserve unsignedness, dimensions, generated expressions, and FK actions through the intermediate representation.
|
||||
|
||||
**Acceptance:** inspect → model → DDL → disposable database → inspect tests compare semantic equivalence for representative MySQL schemas. Unsupported features produce visible, testable failures or warnings, never silent success.
|
||||
|
||||
### A08 — Embed substitution corrupts dollar characters; placeholder parsing ignores SQL syntax
|
||||
|
||||
**P2 / Medium · Engineering, database · Reproduced**
|
||||
|
||||
Evidence: `pkg/assetloader/embed.go:159–160`; `pkg/assetloader/executor.go:32–87`.
|
||||
|
||||
`replaceEmbedPlaceholder` passes file content into the replacement argument of `regexp.ReplaceAllString`. Dollar characters are interpreted as capture references. Embedding `price $1 ${2} $HOME $$` produced `price ; $` in the local reproduction, rather than preserving the input.
|
||||
|
||||
Separately, `BuildQuery` matches placeholders inside quoted strings and comments. `SELECT ':literal', :bytes` fails with an unknown-placeholder error even though `:literal` is SQL string content. Similar text-based processing makes the MySQL executor's `strings.Split(..., ";\n")` fragile for multiline defaults containing that sequence.
|
||||
|
||||
**Fix:** use literal replacement callbacks or explicit span assembly for embed content. Parse SQL lexical contexts for named placeholders. Execute a structured list of generated statements instead of splitting SQL text on delimiters.
|
||||
|
||||
**Acceptance:** dollar text, repeated placeholders, quoted strings, comments, casts, dollar-quoted bodies, and embedded statement delimiters preserve exact intended semantics.
|
||||
|
||||
### A09 — Migration execution lacks a coherent atomicity and concurrency contract
|
||||
|
||||
**P1 / High · Database, engineering · Code-confirmed**
|
||||
|
||||
Evidence: `pkg/writers/pgsql/writer.go:2200–2300`; `pkg/writers/sqlexec/writer.go:107–222`; `pkg/writers/sqlite/writer.go:118–164`; `pkg/writers/mysql/writer.go:165–188`.
|
||||
|
||||
Executors issue independent statements/scripts without a transaction spanning the operation, migration-wide lock, or applied-script ledger in these paths. A failure can leave earlier work committed. Re-running script execution repeats supplied scripts; concurrent invocations can interleave. A script may contain its own transaction, but that does not establish a consistent executor-level guarantee.
|
||||
|
||||
The necessary behavior differs by database and statement type: wrapping all MySQL DDL in a transaction would not provide the same rollback guarantee as transactional PostgreSQL DDL.
|
||||
|
||||
**Fix:** define explicit execution modes with transaction boundaries, concurrency control, retry/recovery semantics, and accurate partial-application reports. If `scripts execute` remains a raw runner, document that clearly. If it is intended as a migration manager, add migration identity, checksums, applied history, and duplicate/concurrent-run protection.
|
||||
|
||||
**Acceptance:** failure injection and simultaneous-run tests establish predictable behavior; supported transactional runs roll back; nontransactional runs report exactly what committed and how to resume safely.
|
||||
|
||||
### A10 — Database operations cannot consistently inherit cancellation or deadlines
|
||||
|
||||
**P2 / Medium · Engineering, operations · Code-confirmed**
|
||||
|
||||
Evidence: `pkg/readers/reader.go`; `pkg/writers/writer.go`; `pkg/writers/pgsql/writer.go:2205`; `pkg/writers/sqlexec/writer.go`; `pkg/readers/mysql/reader.go:25`; `pkg/writers/mysql/writer.go:184`.
|
||||
|
||||
Reader/writer interfaces do not accept a caller context, and several implementations start with `context.Background()`. This disconnects CLI/job cancellation from connection establishment, catalog reads, and SQL execution. A blocked statement or lock wait has no consistent application-level deadline.
|
||||
|
||||
**Fix:** carry caller contexts through reader/writer operations; configure connection, statement, and lock-wait limits separately. Use a signal-aware CLI root context and preserve cancellation errors in job results.
|
||||
|
||||
**Acceptance:** a blocked database query terminates promptly on cancellation or timeout, cleanup is bounded, and the job exits nonzero without starting dependents.
|
||||
|
||||
### A11 — Windows updater executes downloads without artifact authentication
|
||||
|
||||
**P2 / Medium · Security, supply chain · Code-confirmed**
|
||||
|
||||
Evidence: `cmd/relspec/update.go:115–152`; `.gitea/workflows/release.yml`.
|
||||
|
||||
After confirmation or `--yes`, the updater downloads the asset URL from release metadata and starts the resulting executable. There is no signature verification, trusted-publisher check, content-size limit, or asset-origin/redirect restriction in this path. The default release API uses HTTPS, which is a useful transport control, but it is not independent artifact authentication. Compromised release metadata or publication infrastructure could substitute an installer.
|
||||
|
||||
**Fix:** sign installers or release manifests and verify against an independently trusted identity before launch. Require HTTPS throughout redirects, apply a deliberate origin policy, cap downloads, and clean up failed downloads. A checksum fetched from the same compromised endpoint alone is insufficient authentication.
|
||||
|
||||
**Acceptance:** tampered, unsigned, wrong-publisher, oversized, and disallowed-origin assets are rejected before execution.
|
||||
|
||||
### A12 — Connection and development-service defaults permit avoidable exposure
|
||||
|
||||
**P2 / Medium · Security · Code-confirmed**
|
||||
|
||||
Evidence: `pkg/ui/connstring.go:42–49`; `docker-compose.yml`; `Makefile`'s `docker-up` target.
|
||||
|
||||
The UI starts PostgreSQL and SQL Server connection fields with encryption disabled; changing the host does not change that setting. Compose publishes database ports without a loopback host restriction and supplies fixed development passwords. These are development credentials, not evidence of leaked production secrets, but the services can become reachable on host network interfaces.
|
||||
|
||||
**Fix:** default remote connections to authenticated encrypted transport and make local exceptions explicit. Bind development database ports to `127.0.0.1` by default; document how to deliberately expose a test service. Prefer a limited inspection role over administrative defaults.
|
||||
|
||||
**Acceptance:** changing a connection to a remote host does not silently retain an insecure default; standard local test startup only exposes loopback ports.
|
||||
|
||||
### A13 — CI does not consistently test the declared toolchain or live writer paths
|
||||
|
||||
**P1 / High · Maintenance, database assurance · Code-confirmed configuration defects**
|
||||
|
||||
Evidence: `.github/workflows/ci.yml:15,58,79`; `.github/workflows/integration-tests.yml:59–76`; `.gitea/workflows/release.yml:48–49`; `Makefile:92–108`; `tests/dbtest/dbs/mysql.sh`.
|
||||
|
||||
- GitHub CI specifies Go 1.24/1.25 while `go.mod` requires 1.26.9. Automatic toolchain switching can make that matrix misleading; with switching disabled those jobs cannot build the module as declared.
|
||||
- The GitHub integration job runs directly on the host but connects to `postgres`, the service-container name, rather than the published loopback port. GitHub documents `localhost` for this job layout. This networking conclusion applies to GitHub-hosted execution; the Gitea runner configuration was not inspected.
|
||||
- `make test-integration` runs the PostgreSQL reader and `tests/integration`, but omits live tests under `pkg/writers/pgsql` and `pkg/writers/sqlexec`. Release `go test ./...` has no configured database service/connection, so those tests can skip there too.
|
||||
- MySQL has container scaffolding but no Go tests consuming `RELSPEC_TEST_MYSQL_CONN`; this leaves A06 undetected.
|
||||
- `make test-unit` excludes the entire PostgreSQL reader package, including `queries_pure_test.go`.
|
||||
|
||||
**Fix:** use `go-version-file: go.mod`, explicitly test supported toolchains, correct GitHub service networking, and run all live readers and writers against disposable databases in required CI jobs. Make unexpected integration-test skips fail the integration job. Verify which workflows the actual Gitea deployment discovers; CI badges alone do not establish execution.
|
||||
|
||||
**Acceptance:** CI artifacts show real reader/writer execution, not just green packages with skipped live tests, and demonstrate MySQL constraint inspection and failing-migration behavior.
|
||||
|
||||
Reference: [GitHub PostgreSQL service-container networking](https://docs.github.com/en/actions/tutorials/use-containerized-services/create-postgresql-service-containers).
|
||||
|
||||
### A14 — Formatting gate ignores formatter failure
|
||||
|
||||
**P2 / Medium · Maintenance · Reproduced**
|
||||
|
||||
Evidence: `Makefile:62–66`.
|
||||
|
||||
The recipe captures formatter stdout and checks whether it is empty, without preserving the command's failure status. `make fmt-check GOLANGCI_LINT=false` prints `Checking formatting...` and exits successfully. A missing/broken formatter or dependency/toolchain failure can therefore pass this gate without checking formatting.
|
||||
|
||||
**Fix:** explicitly fail when the formatter command fails before inspecting its diff output. Pin the formatter version so the gate is reproducible.
|
||||
|
||||
**Acceptance:** a formatter that exits nonzero with empty stdout fails the target; clean formatting passes; a real diff fails.
|
||||
|
||||
### A15 — Release artifacts can disagree with the requested release tag
|
||||
|
||||
**P2 / Medium · Maintenance, supply chain · Code-confirmed**
|
||||
|
||||
Evidence: `.gitea/workflows/release.yml:55–94,241–269,297–314`; upload steps around lines 113–130, 278–289, and 360–371; `Makefile:84,143,256,273`.
|
||||
|
||||
For manual dispatch with a tag input, checkout does not explicitly select that tag. Binaries and packages are built from the dispatched checkout, while the source archives use the supplied tag and version strings claim that version. These can represent different commits. Uploads use `curl -s` without failing on HTTP error status, so publication failures can also look successful.
|
||||
|
||||
Additional reproducibility gaps: ordinary `make build` runs `go mod tidy` through `deps`; analysis tools use `@latest`; `release-version` lacks test/build prerequisites; `rerelease` force-moves an existing release tag.
|
||||
|
||||
**Fix:** resolve and validate the release tag once, check out its commit in every artifact job, and record that commit in a manifest. Fail on HTTP errors and verify the uploaded inventory. Keep normal builds read-only with respect to dependency manifests, pin build tools, and publish immutable versions with consistent quality gates.
|
||||
|
||||
**Acceptance:** dispatching a historical tag from a newer branch produces binaries and source from the same historical commit; failed uploads fail the workflow; normal builds leave the working tree unchanged.
|
||||
|
||||
### A16 — Output replacement guarantees differ across commands and writers
|
||||
|
||||
**P2 / Medium · Engineering, maintenance · Code-confirmed**
|
||||
|
||||
Evidence: `cmd/relspec/job.go:859–886`; `pkg/writers/template/writer.go:288–308`; `pkg/writers/mysql/writer.go:54–68`; `pkg/writers/json/writer.go:56–58`.
|
||||
|
||||
Job single-file conversion has an atomic-write wrapper, but direct writers and template output write or truncate final paths in place. Some writer cleanup functions ignore close errors. A disk error, failed generation, or interrupted multi-file run can replace a previously valid artifact with incomplete output or leave a mixed generation.
|
||||
|
||||
**Fix:** centralize output staging and finalization for direct CLI use and jobs. Handle close/flush errors, use exclusive unpredictable temporary files, preflight filename collisions, and define a staging/manifest strategy for multi-file generations. Preserve existing files when generation fails.
|
||||
|
||||
**Acceptance:** injected generation/write/finalization failures leave the previous valid output intact and return an error; no command claims success with a partial replacement.
|
||||
|
||||
## Perspective-specific maintenance needs
|
||||
|
||||
These are follow-up investments and scope gaps, not additional proven vulnerabilities.
|
||||
|
||||
### Software engineering
|
||||
|
||||
- **A common capability registry:** format construction is repeated across `convert.go`, `diff.go`, `merge.go`, `inspect.go`, and `edit.go`. Job format validation is another list. Centralize reader/writer creation and capabilities so adding a format does not require synchronized switches throughout the CLI.
|
||||
- **Typed execution options and results:** connection settings, execution counters, and flags are carried through string-keyed metadata maps. Introduce typed configuration/result structures for live execution while retaining metadata for extensions.
|
||||
- **Model validation before generation:** JSON/YAML readers primarily unmarshal and return models. Establish a shared normalization/validation boundary for nil entries, duplicate objects, unresolved references, incompatible attributes, and absent required fields. The inspector is useful but should not be the only optional path to structural safety.
|
||||
- **Smaller database writer components:** `pkg/writers/pgsql/writer.go` is 2,643 lines and combines generation, execution, reporting, type/index policy, and statement parsing. Separate these responsibilities to make security and migration changes easier to review.
|
||||
- **Adversarial parser tests:** no `Fuzz...` tests were found in `pkg` or `cmd`. Add fuzz targets for DBML/directives, ORM readers, embed handling, SQL lexical processing, and invalid intermediate models. Add large-schema benchmarks before introducing concurrency or caching.
|
||||
|
||||
### Security
|
||||
|
||||
- Document the trust model: SQL scripts, expressions/defaults, asset calls, schema metadata, templates, and release assets have different execution and local-file capabilities.
|
||||
- Test redaction across every CLI/library logging path, not just the job logger and masked UI preview. Cover URL-encoded passwords, DSN parse errors, and database error messages without printing real credentials in tests.
|
||||
- Run pinned vulnerability checks in required change-validation workflows and record the tool/database versions. Produce release checksums and an SBOM; signing and verification are covered by A11.
|
||||
- Use scoped database roles: inspection should not require DDL rights, and schema application should not normally use a server administrator account.
|
||||
|
||||
### Maintenance
|
||||
|
||||
- Update contradictory documentation. README advertises Go 1.24.0; CONTRIBUTING says 1.25+; `go.mod` requires 1.26.9. TODO says MySQL, watch mode, type mappings, Windows installer, and installer CI are missing even though implementations exist. `tests/dbtest/README.md` still says there is no MySQL driver/reader.
|
||||
- Document actual feature completeness, not just format names. Distinguish implemented, partial, experimental, and unsupported behavior with links to tests.
|
||||
- The VS Code extension's `renderTemplate` is a placeholder and syntax validation mainly counts delimiters per line (`vscode-extension/relspec-template-editor/src/extension.ts`). Label it experimental or connect it to the actual Go parser/renderer. Reuse and dispose a single diagnostics collection rather than creating one on each validation.
|
||||
- Align GitHub and Gitea workflow ownership, tool versions, required checks, release policies, and developer setup. Add Windows execution tests for filesystem behavior and installer/updater code, rather than relying solely on cross-compilation.
|
||||
|
||||
### Database specialization
|
||||
|
||||
- Publish an object/attribute fidelity matrix covering every supported source/destination, including views, sequences, enums, generated/identity columns, FK actions/deferral, expression/partial indexes, precision/scale, unsigned types, and permissions.
|
||||
- PostgreSQL generation needs explicit fidelity review beyond tables: the model includes views, enums, permissions, and standalone sequences, but the main PostgreSQL writer does not provide corresponding complete emission paths. Its sequence generation focuses on table primary keys; scanning views to infer extensions does not emit the views. Unsupported objects need warnings or rejection.
|
||||
- Establish migration safety policy: destructive-change approval in the product, reviewed plans, populated-data preflight, lock budgets, backfills, recovery, and post-apply verification. A dry run that only validates options is not proof that a migration can execute safely.
|
||||
- Test semantic convergence: after applying a model, re-inspect and diff; a second application should generate no unintended change. Include populated tables, cross-schema dependencies, generated columns, reserved names, and failure recovery.
|
||||
- Keep database-specific transaction and DDL behavior explicit. Do not promise uniform rollback or schema support across PostgreSQL, SQLite, SQL Server, and MySQL.
|
||||
|
||||
## Prioritized repair plan
|
||||
|
||||
| Order | Work | Completion evidence |
|
||||
| --- | --- | --- |
|
||||
| 1 | Fix misleading success and unsafe type fallback: A01–A02 | Failure-injection tests prove nonzero exit and preservation of existing data/schema. |
|
||||
| 2 | Close file and SQL rendering boundaries: A03–A05, A08 | Adversarial filename, embed, identifier, literal, and delimiter cases pass. |
|
||||
| 3 | Repair MySQL reading and fidelity: A06–A07 | Live round trips preserve constraints, indexes, and type dimensions; unsupported features fail explicitly. |
|
||||
| 4 | Restore trustworthy CI coverage: A13–A14 | All relevant live writers run, toolchain matches policy, and tool failures fail gates. |
|
||||
| 5 | Define execution safety: A09–A10 | Documented transaction/concurrency modes, cancellation, recovery, and precise results. |
|
||||
| 6 | Harden distribution and output: A11–A12, A15–A16 | Verified installer artifacts, consistent release commit, safer defaults, and staged file replacement. |
|
||||
| 7 | Reduce maintenance debt | Capability registry, typed options, accurate docs, supported-feature matrix, fuzzing, and measured performance baseline. |
|
||||
|
||||
Avoid expanding the format list or adding a web/API layer until the existing execution and fidelity contracts are dependable. The current code already offers enough functionality that correctness, safety, and maintainability will yield more value than additional breadth.
|
||||
@@ -15,6 +15,7 @@ jobs:
|
||||
mode: table
|
||||
filename_pattern: "{{.Name}}.ts"
|
||||
output:
|
||||
format: text
|
||||
path: "${VST}"
|
||||
overwrite: true
|
||||
|
||||
|
||||
@@ -1,25 +1,26 @@
|
||||
module git.warky.dev/wdevs/relspecgo
|
||||
|
||||
go 1.25.13
|
||||
go 1.26.9
|
||||
|
||||
require (
|
||||
github.com/gdamore/tcell/v2 v2.13.9
|
||||
github.com/gdamore/tcell/v2 v2.13.10
|
||||
github.com/go-sql-driver/mysql v1.10.1
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/jackc/pgx/v5 v5.9.2
|
||||
github.com/microsoft/go-mssqldb v1.10.0
|
||||
github.com/jackc/pgx/v5 v5.11.0
|
||||
github.com/microsoft/go-mssqldb v1.11.2
|
||||
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.18
|
||||
github.com/uptrace/bun/dialect/pgdialect v1.2.18
|
||||
golang.org/x/text v0.39.0
|
||||
github.com/stretchr/testify v1.12.1
|
||||
github.com/uptrace/bun v1.3.0
|
||||
github.com/uptrace/bun/dialect/pgdialect v1.3.0
|
||||
golang.org/x/text v0.43.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
modernc.org/sqlite v1.50.1
|
||||
modernc.org/sqlite v1.60.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
filippo.io/edwards25519 v1.2.0 // indirect
|
||||
github.com/dustin/go-humanize v1.1.0 // indirect
|
||||
github.com/gdamore/encoding v1.0.1 // indirect
|
||||
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect
|
||||
github.com/golang-sql/sqlexp v0.1.0 // indirect
|
||||
@@ -29,25 +30,24 @@ require (
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/kr/pretty v0.3.1 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.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/lucasb-eyer/go-colorful v1.4.1 // indirect
|
||||
github.com/mattn/go-isatty v0.0.24 // indirect
|
||||
github.com/ncruces/go-strftime v1.1.0 // indirect
|
||||
github.com/puzpuzpuz/xsync/v3 v3.5.1 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||
github.com/shopspring/decimal v1.4.0 // indirect
|
||||
github.com/shopspring/decimal v1.5.0 // indirect
|
||||
github.com/spf13/pflag v1.0.10 // indirect
|
||||
github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc // indirect
|
||||
github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect
|
||||
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
|
||||
golang.org/x/crypto v0.53.0 // indirect
|
||||
golang.org/x/net v0.56.0 // indirect
|
||||
golang.org/x/sync v0.21.0 // indirect
|
||||
golang.org/x/sys v0.46.0 // indirect
|
||||
golang.org/x/term v0.44.0 // indirect
|
||||
modernc.org/libc v1.72.3 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.5 // indirect
|
||||
golang.org/x/crypto v0.58.0 // indirect
|
||||
golang.org/x/sync v0.24.0 // indirect
|
||||
golang.org/x/sys v0.49.0 // indirect
|
||||
golang.org/x/term v0.47.0 // indirect
|
||||
modernc.org/libc v1.78.0 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
modernc.org/memory v1.12.1 // indirect
|
||||
)
|
||||
|
||||
@@ -1,34 +1,36 @@
|
||||
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=
|
||||
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
|
||||
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.23.1 h1:zvXfGJCWvywnCA814d8ZiVyt+fm9nnTE8xSb99zRyfo=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.23.1/go.mod h1:iptorS+VYKFL2N6PnebpS91dubG35eAOEERnT4PJbQU=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.14.1 h1:u93s+zU2JD62im61Bm5CZIc1ZrOJaIAWEg0WOrMVkEo=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.14.1/go.mod h1:oXtinPO4OLj9d1DOTrqrL1oRwGhcqadvAmrl6wTeGlk=
|
||||
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/azkeys v1.5.0 h1:MaKvxE6D0KkjOg6Wd9M00iqP5PR0kUxCfiezes4JweM=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.5.0/go.mod h1:i2h9fsTFKZorh8RdV2IcSUf/Qj98GlTkrTvUbX/s8as=
|
||||
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/AzureAD/microsoft-authentication-library-for-go v1.8.0 h1:Nljr4q1GRA/5vCrMONS+g4u4LRHNgOXVSh3O43J2CnI=
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.8.0/go.mod h1:Y33QHnf0FfdVewFFISOGe20mkZbxX4H839o955/PoeI=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/dustin/go-humanize v1.1.0 h1:dbKTrvD0klcbBV/h4AWJdMuZogJACoMlvWIWZ5b2xWg=
|
||||
github.com/dustin/go-humanize v1.1.0/go.mod h1:hc1CvRkJMsgxqjmjMQF3QNRAZBwY8AXBAzKYoSX9sFI=
|
||||
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.13.9 h1:uI5l3DYPcFvHINKlGft+en23evOKL+dwtD21QR8ejVA=
|
||||
github.com/gdamore/tcell/v2 v2.13.9/go.mod h1:+Wfe208WDdB7INEtCsNrAN6O2m+wsTPk1RAovjaILlo=
|
||||
github.com/gdamore/tcell/v2 v2.13.10 h1:Afs3JKt83HnhuUKdZ3MnxUgOqQRWftj5JyDqv1LLynA=
|
||||
github.com/gdamore/tcell/v2 v2.13.10/go.mod h1:+Wfe208WDdB7INEtCsNrAN6O2m+wsTPk1RAovjaILlo=
|
||||
github.com/go-sql-driver/mysql v1.10.1 h1:arlSnNLq6a5yxGxV7qg9lF4j0C+KwD6NbQyKr9QL6ME=
|
||||
github.com/go-sql-driver/mysql v1.10.1/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA=
|
||||
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
|
||||
github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A=
|
||||
github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
|
||||
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
@@ -39,8 +41,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.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
|
||||
github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||
github.com/jackc/pgx/v5 v5.11.0 h1:IzBBtyK9AHqf98cctWFifYSci2hgQR/cd56wB4p+ogg=
|
||||
github.com/jackc/pgx/v5 v5.11.0/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=
|
||||
@@ -51,18 +53,17 @@ 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.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/lucasb-eyer/go-colorful v1.4.1 h1:1EO+WB73+EH8EVbzlrG3KLAfEypQWVHIBqlTf+2hNss=
|
||||
github.com/lucasb-eyer/go-colorful v1.4.1/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
|
||||
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
|
||||
github.com/microsoft/go-mssqldb v1.11.2 h1:FCgeBIK8um2+X4tbun6Q71N1KsfyCDPKY41e1yGVjSE=
|
||||
github.com/microsoft/go-mssqldb v1.11.2/go.mod h1:CYgwG5AMXFojbjTg+GNP5G/y6uz1BhTyZaPqQWzkGnQ=
|
||||
github.com/ncruces/go-strftime v1.1.0 h1:vtPmPWMkb539RGV9WpWT9EwJGvrOCKvMNI4obWL47Hk=
|
||||
github.com/ncruces/go-strftime v1.1.0/go.mod h1:yVaq7iwCN09vHRrKBrrflXT0M9tTMG2KmZ9Fj7xeG/g=
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
|
||||
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/puzpuzpuz/xsync/v3 v3.5.1 h1:GJYJZwO6IdxN/IKbneznS6yPkVC+c3zyY/j19c++5Fg=
|
||||
github.com/puzpuzpuz/xsync/v3 v3.5.1/go.mod h1:VjzYrABPabuM4KyBh1Ftq6u8nhwY5tBPKP9jpmh0nnA=
|
||||
@@ -76,8 +77,8 @@ github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/f
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
|
||||
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
|
||||
github.com/shopspring/decimal v1.5.0 h1:HxSWk31RmJv0EyY4oLiGQB9u7WPludUwPBdvQZnYFPc=
|
||||
github.com/shopspring/decimal v1.5.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
|
||||
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
@@ -86,65 +87,67 @@ github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3A
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
|
||||
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
|
||||
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.18 h1:3HnRcMfS6OBPMG1eSOzlbFJ/X/AyMEJb7rMxE6VQvDU=
|
||||
github.com/uptrace/bun v1.2.18/go.mod h1:wNltaKJk4JtOt4SG5I5zmA7v0/Mzjh1+/S906Rayd3Y=
|
||||
github.com/uptrace/bun/dialect/pgdialect v1.2.18 h1:IZ6nM2+OYrL8lkEAy7UkSEZvoa3vluTAUlZfPtlRB2k=
|
||||
github.com/uptrace/bun/dialect/pgdialect v1.2.18/go.mod h1:Tqdf4QP1okrGYpXfodXvCOK6Ob1OOTwSaoAzCgBB3IU=
|
||||
github.com/uptrace/bun v1.3.0 h1:hUcbfRpbQk4/upFSI+uZf6jSJw5A0lkIKt0XdpvbUZo=
|
||||
github.com/uptrace/bun v1.3.0/go.mod h1:wNltaKJk4JtOt4SG5I5zmA7v0/Mzjh1+/S906Rayd3Y=
|
||||
github.com/uptrace/bun/dialect/pgdialect v1.3.0 h1:0ZkGqGugxaPCxko4DV2f4d7i+FnM2Qj3rNpChfhiv54=
|
||||
github.com/uptrace/bun/dialect/pgdialect v1.3.0/go.mod h1:kP5HGEpAX8iJCqj3WHm04XnoeBPacdCN1P2HiwMtHpg=
|
||||
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=
|
||||
github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
|
||||
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
|
||||
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.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
||||
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
||||
golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54=
|
||||
golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w=
|
||||
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.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
|
||||
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
|
||||
golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c=
|
||||
golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o=
|
||||
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.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
||||
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
|
||||
golang.org/x/net v0.60.0 h1:79p50tfZlm0J9YfoDsSi639qSXNGVwEzOPLCxM2FsYU=
|
||||
golang.org/x/net v0.60.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
|
||||
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.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
|
||||
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA=
|
||||
golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
|
||||
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.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
|
||||
golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||
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.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
|
||||
golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
|
||||
golang.org/x/term v0.47.0 h1:cpyOwQkr2Nzxe2p6KnrTG7Ei2wtzjsuSjhpXXepZ/BM=
|
||||
golang.org/x/term v0.47.0/go.mod h1:efAfAuG6RKABvg1ArGebRkvX6LXul14SM67o2e4mUCY=
|
||||
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.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus=
|
||||
golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM=
|
||||
golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc=
|
||||
golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s=
|
||||
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.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
|
||||
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
|
||||
golang.org/x/tools v0.51.0 h1:k4Xc/1Om9jwkBJBo4NVLMSARBoWtK10mx+W5BnXCeAI=
|
||||
golang.org/x/tools v0.51.0/go.mod h1:9eEncMayCV6zRMGhR5eZEC2iBx98qWcF1HZ9Z7wJOoA=
|
||||
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=
|
||||
@@ -152,30 +155,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.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/cc/v4 v4.30.1 h1:ZXInYH2CD24GPWSwOFPwB6JrnFt4/6d7ceW1Ukd30Bk=
|
||||
modernc.org/cc/v4 v4.30.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||
modernc.org/ccgo/v4 v4.36.2 h1:j6d5HuXtBxF00vEcI5RE0O6EIrG6RyRpgMiGV2IK9ys=
|
||||
modernc.org/ccgo/v4 v4.36.2/go.mod h1:Wh8QlPZq/US2DrsCc31FxJVBOEEcN2BBfla6OQDHydA=
|
||||
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
|
||||
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||
modernc.org/gc/v3 v3.2.0 h1:/Y3LGDMQaoYf55PebP1qsZ5FDGgAZyG1RYKEHeQzh1o=
|
||||
modernc.org/gc/v3 v3.2.0/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU=
|
||||
modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs=
|
||||
modernc.org/libc v1.78.0 h1:Lpm+e9K6ruVYEGwWfuBO1xXtYEaXDouFOG1SuWjFDP4=
|
||||
modernc.org/libc v1.78.0/go.mod h1:wvxR55nOF2/66NkJoNV7/coXBTCvT3HR22rcqW6lOuA=
|
||||
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/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g=
|
||||
modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
|
||||
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.50.1 h1:l+cQvn0sd0zJJtfygGHuQJ5AjlrwXmWPw4KP3ZMwr9w=
|
||||
modernc.org/sqlite v1.50.1/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM=
|
||||
modernc.org/sqlite v1.60.1 h1:/blz53O951KWFOso4QQvEs/Fq6cDBKLtMVrYNSeJVKw=
|
||||
modernc.org/sqlite v1.60.1/go.mod h1:1dIoEagfDE72QytD5scH1lxARtaUgKgHC/NuApA27r0=
|
||||
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=
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
# Maintainer: Hein (Warky Devs) <hein@warky.dev>
|
||||
pkgname=relspec-bin
|
||||
pkgver=1.0.91
|
||||
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')
|
||||
provides=('relspec')
|
||||
conflicts=('relspec')
|
||||
options=('!debug')
|
||||
source_x86_64=("$pkgname-$pkgver-x86_64.tar.gz::$url/releases/download/v$pkgver/relspec_${pkgver}_linux_amd64.tar.gz")
|
||||
source_aarch64=("$pkgname-$pkgver-aarch64.tar.gz::$url/releases/download/v$pkgver/relspec_${pkgver}_linux_arm64.tar.gz")
|
||||
sha256sums_x86_64=('SKIP')
|
||||
sha256sums_aarch64=('SKIP')
|
||||
|
||||
package() {
|
||||
# Binary
|
||||
install -Dm755 "relspec" "$pkgdir/usr/bin/relspec"
|
||||
install -Dm644 "LICENSE" "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
|
||||
|
||||
# Default config dir
|
||||
install -dm755 "$pkgdir/etc/relspec"
|
||||
}
|
||||
@@ -1,17 +1,17 @@
|
||||
# Maintainer: Hein (Warky Devs) <hein@warky.dev>
|
||||
pkgname=relspec
|
||||
pkgver=1.0.82
|
||||
pkgver=1.0.91
|
||||
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")
|
||||
source=("$pkgname-$pkgver.zip::$url/releases/download/v$pkgver/relspec-v$pkgver-source.zip")
|
||||
sha256sums=('SKIP')
|
||||
|
||||
build() {
|
||||
cd "relspecgo"
|
||||
cd "$pkgname-$pkgver"
|
||||
export CGO_ENABLED=0
|
||||
go build \
|
||||
-trimpath \
|
||||
@@ -20,12 +20,12 @@ build() {
|
||||
}
|
||||
|
||||
check() {
|
||||
cd "relspecgo"
|
||||
cd "$pkgname-$pkgver"
|
||||
go test ./...
|
||||
}
|
||||
|
||||
package() {
|
||||
cd "relspecgo"
|
||||
cd "$pkgname-$pkgver"
|
||||
|
||||
# Binary
|
||||
install -Dm755 "$pkgname" "$pkgdir/usr/bin/$pkgname"
|
||||
@@ -1,5 +1,5 @@
|
||||
Name: relspec
|
||||
Version: 1.0.82
|
||||
Version: 1.0.91
|
||||
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.
|
||||
|
||||
|
||||
+93
-18
@@ -9,10 +9,22 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/safefile"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/sqllex"
|
||||
)
|
||||
|
||||
const ScriptSourcePathMetadataKey = "source_path"
|
||||
|
||||
const DefaultMaxAssetBytes int64 = 64 << 20 // 64 MiB per manifest/embedded asset
|
||||
|
||||
// EmbedOptions are trusted caller configuration, not script/model metadata.
|
||||
// An empty AssetRoot defaults to the SQL file's directory; external reads are rejected.
|
||||
type EmbedOptions struct {
|
||||
AssetRoot string
|
||||
MaxFileBytes int64
|
||||
}
|
||||
|
||||
var (
|
||||
embedDirectivePattern = regexp.MustCompile(`(?m)^\s*--\s*@embed:\s*(.+?)\s*$`)
|
||||
embedAttrPattern = regexp.MustCompile(`([a-zA-Z_][a-zA-Z0-9_]*)=("[^"]*"|'[^']*'|\S+)`)
|
||||
@@ -27,7 +39,28 @@ var (
|
||||
// string literal. Base64 mode embeds a quoted base64 literal suitable for
|
||||
// decode(:var, 'base64').
|
||||
func ProcessEmbedDirectives(sqlPath, sql string) (string, error) {
|
||||
directives := embedDirectivePattern.FindAllStringSubmatch(sql, -1)
|
||||
return ProcessEmbedDirectivesWithOptions(sqlPath, sql, EmbedOptions{})
|
||||
}
|
||||
|
||||
func embedDirectives(sql string) (result string, directives []string) {
|
||||
// Only standalone line comments are directives, and only in SQL code.
|
||||
result = sqllex.Walk(sql, nil, func(comment string) string {
|
||||
if match := embedDirectivePattern.FindStringSubmatch(comment); match != nil {
|
||||
directives = append(directives, match[1])
|
||||
return ""
|
||||
}
|
||||
return comment
|
||||
})
|
||||
return result, directives
|
||||
}
|
||||
|
||||
func HasEmbedDirectives(sql string) bool {
|
||||
_, directives := embedDirectives(sql)
|
||||
return len(directives) > 0
|
||||
}
|
||||
|
||||
func ProcessEmbedDirectivesWithOptions(sqlPath, sql string, options EmbedOptions) (string, error) {
|
||||
result, directives := embedDirectives(sql)
|
||||
if len(directives) == 0 {
|
||||
return sql, nil
|
||||
}
|
||||
@@ -36,22 +69,63 @@ func ProcessEmbedDirectives(sqlPath, sql string) (string, error) {
|
||||
return "", fmt.Errorf("sql path is required for embed directives")
|
||||
}
|
||||
|
||||
result := embedDirectivePattern.ReplaceAllString(sql, "")
|
||||
assetRoot := options.AssetRoot
|
||||
if assetRoot == "" {
|
||||
assetRoot = filepath.Dir(sqlPath)
|
||||
}
|
||||
absRoot, err := filepath.Abs(assetRoot)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
absSQL, err := filepath.Abs(sqlPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
scriptRel, err := filepath.Rel(absRoot, absSQL)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if _, err := safefile.Relative(filepath.ToSlash(scriptRel)); err != nil {
|
||||
return "", fmt.Errorf("SQL source path is outside asset root: %w", err)
|
||||
}
|
||||
root, err := openAssetRoot(absRoot)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer root.Close()
|
||||
limit := options.MaxFileBytes
|
||||
if limit == 0 {
|
||||
limit = DefaultMaxAssetBytes
|
||||
}
|
||||
literals := map[string]string{}
|
||||
used := map[string]bool{}
|
||||
for i, directive := range directives {
|
||||
literal, placeholder, err := embedDirectiveLiteral(sqlPath, directive[1], i+1)
|
||||
literal, placeholder, err := embedDirectiveLiteral(root, filepath.Dir(scriptRel), limit, sqlPath, directive, i+1)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !embedPlaceholderPattern(placeholder).MatchString(result) {
|
||||
return "", fmt.Errorf("%s embed directive %d: placeholder %s not found", sqlPath, i+1, placeholder)
|
||||
if _, exists := literals[placeholder]; exists {
|
||||
return "", fmt.Errorf("duplicate embed placeholder %s", placeholder)
|
||||
}
|
||||
literals[placeholder] = literal
|
||||
}
|
||||
result = sqllex.Walk(result, func(name string) string {
|
||||
if literal, ok := literals[name]; ok {
|
||||
used[name] = true
|
||||
return literal
|
||||
}
|
||||
return name
|
||||
}, nil)
|
||||
for name := range literals {
|
||||
if !used[name] {
|
||||
return "", fmt.Errorf("%s: embed placeholder %s not found in executable SQL", sqlPath, name)
|
||||
}
|
||||
result = replaceEmbedPlaceholder(result, placeholder, literal)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func embedDirectiveLiteral(sqlPath, raw string, directiveNumber int) (literal, placeholder string, err error) {
|
||||
func embedDirectiveLiteral(root *os.Root, scriptDir string, maxBytes int64, sqlPath, raw string, directiveNumber int) (literal, placeholder string, err error) {
|
||||
attrs, err := parseEmbedAttrs(raw)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("%s embed directive %d: %w", sqlPath, directiveNumber, err)
|
||||
@@ -70,8 +144,12 @@ func embedDirectiveLiteral(sqlPath, raw string, directiveNumber int) (literal, p
|
||||
return "", "", fmt.Errorf("%s embed directive %d: mode must be text or base64", sqlPath, directiveNumber)
|
||||
}
|
||||
|
||||
resolved := filepath.Join(filepath.Dir(sqlPath), filepath.Clean(pathValue))
|
||||
data, err := os.ReadFile(resolved)
|
||||
relative, err := safefile.Relative(pathValue)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("%s embed directive %d: %w", sqlPath, directiveNumber, err)
|
||||
}
|
||||
resolved := filepath.Join(scriptDir, relative)
|
||||
data, err := safefile.Read(root, resolved, maxBytes)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("%s embed directive %d: reading %s: %w", sqlPath, directiveNumber, resolved, err)
|
||||
}
|
||||
@@ -153,13 +231,10 @@ func sqlStringLiteral(value string) (string, error) {
|
||||
if strings.ContainsRune(value, '\x00') {
|
||||
return "", fmt.Errorf("embedded text contains NUL byte")
|
||||
}
|
||||
return "'" + strings.ReplaceAll(value, "'", "''") + "'", nil
|
||||
}
|
||||
|
||||
func replaceEmbedPlaceholder(sql, placeholder, literal string) string {
|
||||
return embedPlaceholderPattern(placeholder).ReplaceAllString(sql, "${1}"+literal+"${2}")
|
||||
}
|
||||
|
||||
func embedPlaceholderPattern(placeholder string) *regexp.Regexp {
|
||||
return regexp.MustCompile(`(^|[^a-zA-Z0-9_:])` + regexp.QuoteMeta(placeholder) + `([^a-zA-Z0-9_]|$)`)
|
||||
prefix := ""
|
||||
if strings.Contains(value, "\\") {
|
||||
prefix = "E"
|
||||
value = strings.ReplaceAll(value, "\\", "\\\\")
|
||||
}
|
||||
return prefix + "'" + strings.ReplaceAll(value, "'", "''") + "'", nil
|
||||
}
|
||||
|
||||
@@ -141,3 +141,114 @@ SELECT :body, :body_extra;
|
||||
t.Fatalf("placeholder boundary was not respected:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbedRejectsTraversalAbsolutePathsAndSymlinks(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
scriptDir := filepath.Join(root, "scripts")
|
||||
if err := os.Mkdir(scriptDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
secret := filepath.Join(root, "secret.txt")
|
||||
if err := os.WriteFile(secret, []byte("never print this secret"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, path := range []string{"../secret.txt", secret, `..\secret.txt`, `C:\secret.txt`, `\\server\secret.txt`} {
|
||||
sql := "-- @embed: path='" + path + "' var=:body mode=text\nSELECT :body;"
|
||||
got, err := ProcessEmbedDirectives(filepath.Join(scriptDir, "seed.sql"), sql)
|
||||
if err == nil || got != "" {
|
||||
t.Fatalf("unsafe embed path accepted %q: %q %v", path, got, err)
|
||||
}
|
||||
if strings.Contains(err.Error(), "never print this secret") {
|
||||
t.Fatal("diagnostic leaked asset bytes")
|
||||
}
|
||||
}
|
||||
if err := os.Symlink(root, filepath.Join(scriptDir, "link")); err != nil {
|
||||
t.Skip(err)
|
||||
}
|
||||
if _, err := ProcessEmbedDirectives(filepath.Join(scriptDir, "seed.sql"), "-- @embed: path=link/secret.txt var=:body mode=text\nSELECT :body;"); err == nil {
|
||||
t.Fatal("symlink parent must be rejected")
|
||||
}
|
||||
if err := os.Symlink(secret, filepath.Join(scriptDir, "leaf.txt")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := ProcessEmbedDirectives(filepath.Join(scriptDir, "seed.sql"), "-- @embed: path=leaf.txt var=:body mode=text\nSELECT :body;"); err == nil {
|
||||
t.Fatal("symlink asset must be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbedExplicitRootAndSizeLimit(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
dir := filepath.Join(root, "scripts")
|
||||
if err := os.Mkdir(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "body.txt"), []byte("12345"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sql := "-- @embed: path=body.txt var=:body mode=text\nSELECT :body;"
|
||||
path := filepath.Join(dir, "seed.sql")
|
||||
if _, err := ProcessEmbedDirectivesWithOptions(path, sql, EmbedOptions{AssetRoot: root, MaxFileBytes: 4}); err == nil || !strings.Contains(err.Error(), "byte limit") {
|
||||
t.Fatalf("expected size limit error: %v", err)
|
||||
}
|
||||
if got, err := ProcessEmbedDirectivesWithOptions(path, sql, EmbedOptions{AssetRoot: root, MaxFileBytes: 5}); err != nil || !strings.Contains(got, "SELECT '12345';") {
|
||||
t.Fatalf("valid bounded embed: %q %v", got, err)
|
||||
}
|
||||
if _, err := ProcessEmbedDirectivesWithOptions(path, sql, EmbedOptions{AssetRoot: t.TempDir()}); err == nil {
|
||||
t.Fatal("source path outside explicit root must fail")
|
||||
}
|
||||
if _, err := ProcessEmbedDirectivesWithOptions(path, sql, EmbedOptions{MaxFileBytes: -1}); err == nil {
|
||||
t.Fatal("negative limits must fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestManifestReadRechecksContainmentAndSize(t *testing.T) {
|
||||
root, external := t.TempDir(), t.TempDir()
|
||||
file := filepath.Join(root, "body.txt")
|
||||
if err := os.WriteFile(file, []byte("valid"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
item := Item{AssetRoot: root, MaxFileBytes: 4, Entry: ManifestEntry{File: file}}
|
||||
if _, err := readItemAsset(item); err == nil {
|
||||
t.Fatal("manifest size bound must apply at execution")
|
||||
}
|
||||
item.MaxFileBytes = 5
|
||||
if got, err := readItemAsset(item); err != nil || string(got) != "valid" {
|
||||
t.Fatalf("valid manifest asset: %q %v", got, err)
|
||||
}
|
||||
secret := filepath.Join(external, "secret.txt")
|
||||
if err := os.WriteFile(secret, []byte("secret"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Remove(file); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(secret, file); err != nil {
|
||||
t.Skip(err)
|
||||
}
|
||||
if _, err := readItemAsset(item); err == nil {
|
||||
t.Fatal("replacement symlink after scanning must fail")
|
||||
}
|
||||
item.Entry.File = secret
|
||||
if _, err := readItemAsset(item); err == nil {
|
||||
t.Fatal("forged asset path must fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbedDefaultLimitRejectsOversizedFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
file, err := os.Create(filepath.Join(dir, "large.bin"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := file.Truncate(DefaultMaxAssetBytes + 1); err != nil {
|
||||
file.Close()
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := file.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = ProcessEmbedDirectives(filepath.Join(dir, "seed.sql"), "-- @embed: path=large.bin var=:payload mode=base64\nSELECT :payload;")
|
||||
if err == nil || !strings.Contains(err.Error(), "byte limit") {
|
||||
t.Fatalf("expected default size bound: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,20 +3,13 @@ package assetloader
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/sqllex"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// namedPlaceholder matches :identifier patterns (but not ::cast syntax).
|
||||
var namedPlaceholder = regexp.MustCompile(`:([a-zA-Z_][a-zA-Z0-9_]*)`)
|
||||
|
||||
// pgCastMarker temporarily replaces :: to protect PostgreSQL cast syntax.
|
||||
const pgCastMarker = "\x00PGCAST\x00"
|
||||
|
||||
// BuildQuery converts a SQL call that uses :name named placeholders into a
|
||||
// pgx-compatible positional-parameter query ($1, $2, …) and returns the
|
||||
// corresponding argument slice.
|
||||
@@ -30,13 +23,10 @@ const pgCastMarker = "\x00PGCAST\x00"
|
||||
// placeholder (not built-in and not in staticParams) returns an error.
|
||||
// PostgreSQL cast syntax (::type) is left untouched.
|
||||
func BuildQuery(call string, fileBytes []byte, filename string, staticParams map[string]string) (query string, args []any, err error) {
|
||||
// Protect :: casts before running the placeholder regex.
|
||||
protected := strings.ReplaceAll(call, "::", pgCastMarker)
|
||||
|
||||
paramIndex := map[string]int{} // name → 1-based position
|
||||
var firstErr error
|
||||
|
||||
query = namedPlaceholder.ReplaceAllStringFunc(protected, func(match string) string {
|
||||
query = sqllex.Walk(call, func(match string) string {
|
||||
if firstErr != nil {
|
||||
return match
|
||||
}
|
||||
@@ -70,15 +60,12 @@ func BuildQuery(call string, fileBytes []byte, filename string, staticParams map
|
||||
paramIndex[name] = idx
|
||||
args = append(args, val)
|
||||
return fmt.Sprintf("$%d", idx)
|
||||
})
|
||||
}, nil)
|
||||
|
||||
if firstErr != nil {
|
||||
return "", nil, firstErr
|
||||
}
|
||||
|
||||
// Restore :: casts.
|
||||
query = strings.ReplaceAll(query, pgCastMarker, "::")
|
||||
|
||||
return query, args, nil
|
||||
}
|
||||
|
||||
@@ -86,7 +73,7 @@ func BuildQuery(call string, fileBytes []byte, filename string, staticParams map
|
||||
// absolute path set by ScanDir) and executes the configured SQL call via conn.
|
||||
// The file's raw bytes are bound as a []byte parameter — no encoding or escaping.
|
||||
func ExecuteItem(ctx context.Context, conn *pgx.Conn, item Item) error {
|
||||
data, err := os.ReadFile(item.Entry.File)
|
||||
data, err := readItemAsset(item)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reading asset file %s: %w", item.Entry.File, err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package assetloader
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/safefile"
|
||||
)
|
||||
|
||||
func openAssetRoot(path string) (*os.Root, error) {
|
||||
if err := safefile.CheckRoot(path); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return os.OpenRoot(path)
|
||||
}
|
||||
|
||||
func readItemAsset(item Item) ([]byte, error) {
|
||||
if item.AssetRoot == "" {
|
||||
return nil, fmt.Errorf("asset root is required for manifest execution")
|
||||
}
|
||||
root, err := openAssetRoot(item.AssetRoot)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer root.Close()
|
||||
relative, err := filepath.Rel(item.AssetRoot, item.Entry.File)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
limit := item.MaxFileBytes
|
||||
if limit == 0 {
|
||||
limit = DefaultMaxAssetBytes
|
||||
}
|
||||
return safefile.Read(root, relative, limit)
|
||||
}
|
||||
+38
-30
@@ -18,9 +18,10 @@ import (
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/safefile"
|
||||
)
|
||||
|
||||
// ManifestEntry describes a single file to load from an assets.yaml manifest.
|
||||
@@ -37,6 +38,9 @@ type ManifestEntry struct {
|
||||
// Item combines a manifest entry with its ordering metadata and the resolved
|
||||
// directory where the manifest and asset file reside.
|
||||
type Item struct {
|
||||
// AssetRoot is the trusted scan root, retained for validation at execution time.
|
||||
AssetRoot string
|
||||
MaxFileBytes int64
|
||||
// Priority and Sequence come from the parent directory's naming pattern.
|
||||
Priority int
|
||||
Sequence uint
|
||||
@@ -57,7 +61,12 @@ var dirPattern = regexp.MustCompile(`^(\d+)[_-](\d+)[_-](.+)$`)
|
||||
// absent or contains invalid YAML.
|
||||
func LoadManifest(dir string) ([]ManifestEntry, error) {
|
||||
manifestPath := filepath.Join(dir, "assets.yaml")
|
||||
data, err := os.ReadFile(manifestPath)
|
||||
root, err := openAssetRoot(dir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer root.Close()
|
||||
data, err := safefile.Read(root, "assets.yaml", DefaultMaxAssetBytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading %s: %w", manifestPath, err)
|
||||
}
|
||||
@@ -92,7 +101,7 @@ func ScanDir(baseDir string) ([]Item, error) {
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
if d.Name() != "assets.yaml" {
|
||||
if d.Type()&os.ModeSymlink != 0 || d.Name() != "assets.yaml" {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -119,10 +128,12 @@ func ScanDir(baseDir string) ([]Item, error) {
|
||||
}
|
||||
|
||||
items = append(items, Item{
|
||||
Priority: priority,
|
||||
Sequence: sequence,
|
||||
DirName: dirName,
|
||||
Dir: manifestDir,
|
||||
AssetRoot: absBase,
|
||||
MaxFileBytes: DefaultMaxAssetBytes,
|
||||
Priority: priority,
|
||||
Sequence: sequence,
|
||||
DirName: dirName,
|
||||
Dir: manifestDir,
|
||||
Entry: ManifestEntry{
|
||||
File: resolved, // absolute path, safe to read
|
||||
Call: entry.Call,
|
||||
@@ -170,31 +181,28 @@ func parseDirName(dir string) (priority int, sequence uint, name string) {
|
||||
// Returns the absolute path, a skip flag (true when the entry should be silently
|
||||
// dropped), and any hard error.
|
||||
func resolveAssetPath(absBase, manifestDir, file string) (absPath string, skip bool, err error) {
|
||||
// Clean and join before any symlink resolution so we can detect traversal.
|
||||
joined := filepath.Join(manifestDir, filepath.Clean(file))
|
||||
|
||||
// Ensure the cleaned path is still inside absBase.
|
||||
rel, err := filepath.Rel(absBase, joined)
|
||||
if err != nil || strings.HasPrefix(rel, "..") {
|
||||
// Path escapes the base directory; skip silently.
|
||||
if _, err := safefile.Relative(file); err != nil {
|
||||
return "", true, nil
|
||||
}
|
||||
|
||||
// Walk each component to detect symlinks.
|
||||
parts := strings.Split(rel, string(filepath.Separator))
|
||||
current := absBase
|
||||
for _, part := range parts {
|
||||
current = filepath.Join(current, part)
|
||||
info, statErr := os.Lstat(current)
|
||||
if statErr != nil {
|
||||
// File doesn't exist; skip.
|
||||
return "", true, nil
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 {
|
||||
// Symlink in path; skip silently.
|
||||
return "", true, nil
|
||||
}
|
||||
joined := filepath.Join(manifestDir, file)
|
||||
rel, err := filepath.Rel(absBase, joined)
|
||||
if err != nil {
|
||||
return "", true, nil
|
||||
}
|
||||
root, err := openAssetRoot(absBase)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
defer root.Close()
|
||||
if err := safefile.Check(root, rel); err != nil {
|
||||
return "", true, nil
|
||||
}
|
||||
info, err := root.Stat(rel)
|
||||
if err != nil || !info.Mode().IsRegular() {
|
||||
return "", true, nil
|
||||
}
|
||||
if info.Size() > DefaultMaxAssetBytes {
|
||||
return "", false, fmt.Errorf("asset %q exceeds %d byte limit", rel, DefaultMaxAssetBytes)
|
||||
}
|
||||
|
||||
return joined, false, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package assetloader
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBuildQuerySQLContexts(t *testing.T) {
|
||||
call := `SELECT ':unknown', ":unknown", $$:unknown$$, E'it\':unknown', :bytes::bytea, :filename, :bytes /* :unknown /* :unknown */ */ -- :unknown
|
||||
`
|
||||
got, args, err := BuildQuery(call, []byte("$1"), "File.txt", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(args) != 2 || !strings.Contains(got, "$1::bytea, $2, $1") || !strings.Contains(got, "$$:unknown$$") {
|
||||
t.Fatalf("%s %#v", got, args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbedLiteralOnePassAndSQLContexts(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
data := "price $1 ${2} $HOME $$ :b 'quote' \\path"
|
||||
for file, text := range map[string]string{"a.txt": data, "b.txt": "Second"} {
|
||||
if err := os.WriteFile(filepath.Join(dir, file), []byte(text), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
sql := `-- @embed: path=a.txt var=:a mode=text
|
||||
-- @embed: path=b.txt var=:b mode=text
|
||||
SELECT :a,:a,:b, ':a', ":a", $$:a$$, 1::integer; -- :a
|
||||
/* :a */`
|
||||
got, err := ProcessEmbedDirectives(filepath.Join(dir, "script.sql"), sql)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
literal, err := sqlStringLiteral(data)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Count(got, literal) != 2 || !strings.Contains(got, "'Second'") || !strings.Contains(got, "':a'") || !strings.Contains(got, "$$:a$$") {
|
||||
t.Fatalf("%s", got)
|
||||
}
|
||||
|
||||
for _, sql := range []string{"SELECT $body$\n-- @embed: path=missing var=:x mode=text\n$body$", "SELECT '\n-- @embed: path=missing var=:x mode=text\n'", "/*\n-- @embed: path=missing var=:x mode=text\n*/", "SELECT 1; -- @embed: path=missing var=:x mode=text"} {
|
||||
if HasEmbedDirectives(sql) {
|
||||
t.Fatalf("false directive: %s", sql)
|
||||
}
|
||||
got, err := ProcessEmbedDirectives("", sql)
|
||||
if err != nil || got != sql {
|
||||
t.Fatalf("%q: %v", got, err)
|
||||
}
|
||||
}
|
||||
for _, sql := range []string{"-- @embed: path=a.txt var=:a mode=text\nSELECT ':a'", "-- @embed: path=a.txt var=:a mode=text\n-- @embed: path=b.txt var=:a mode=text\nSELECT :a"} {
|
||||
if _, err := ProcessEmbedDirectives(filepath.Join(dir, "script.sql"), sql); err == nil {
|
||||
t.Fatal("expected invalid directive use")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -71,6 +71,13 @@ func compareSchemas(source, target []*models.Schema) *SchemaDiff {
|
||||
return diff
|
||||
}
|
||||
|
||||
func (c *SchemaChange) addChange(field string, source, target any) {
|
||||
if c.Changes == nil {
|
||||
c.Changes = make(map[string]any)
|
||||
}
|
||||
c.Changes[field] = map[string]any{"source": source, "target": target}
|
||||
}
|
||||
|
||||
func compareSchemaDetails(source, target *models.Schema) *SchemaChange {
|
||||
change := &SchemaChange{
|
||||
Name: source.Name,
|
||||
@@ -78,6 +85,16 @@ func compareSchemaDetails(source, target *models.Schema) *SchemaChange {
|
||||
|
||||
hasChanges := false
|
||||
|
||||
// Compare schema attributes
|
||||
if source.Description != target.Description {
|
||||
change.addChange("description", source.Description, target.Description)
|
||||
hasChanges = true
|
||||
}
|
||||
if source.Owner != target.Owner {
|
||||
change.addChange("owner", source.Owner, target.Owner)
|
||||
hasChanges = true
|
||||
}
|
||||
|
||||
// Compare tables
|
||||
tableDiff := compareTables(source.Tables, target.Tables)
|
||||
if !isEmpty(tableDiff) {
|
||||
@@ -487,6 +504,9 @@ func compareConstraintDetails(source, target *models.Constraint) map[string]any
|
||||
if normalizeConstraintAction(source.OnUpdate) != normalizeConstraintAction(target.OnUpdate) {
|
||||
changes["on_update"] = map[string]string{"source": source.OnUpdate, "target": target.OnUpdate}
|
||||
}
|
||||
if !models.DeferralMatches(source, target) {
|
||||
changes["deferral"] = map[string]string{"source": source.DeferralClause(), "target": target.DeferralClause()}
|
||||
}
|
||||
|
||||
return changes
|
||||
}
|
||||
|
||||
@@ -0,0 +1,345 @@
|
||||
package diff
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||
)
|
||||
|
||||
func TestCompareSchemaDetails(t *testing.T) {
|
||||
mk := func() *models.Schema {
|
||||
s := models.InitSchema("public")
|
||||
s.Tables = []*models.Table{models.InitTable("t", "public")}
|
||||
return s
|
||||
}
|
||||
|
||||
if got := compareSchemaDetails(mk(), mk()); got != nil {
|
||||
t.Errorf("identical schemas must yield nil, got %+v", got)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
mutate func(*models.Schema)
|
||||
check func(*SchemaChange) bool
|
||||
}{
|
||||
{
|
||||
"table added", func(s *models.Schema) { s.Tables = append(s.Tables, models.InitTable("u", "public")) },
|
||||
func(c *SchemaChange) bool { return c.Tables != nil && len(c.Tables.Extra) == 1 },
|
||||
},
|
||||
{
|
||||
"view added", func(s *models.Schema) { s.Views = []*models.View{models.InitView("v", "public")} },
|
||||
func(c *SchemaChange) bool { return c.Views != nil && len(c.Views.Extra) == 1 },
|
||||
},
|
||||
{
|
||||
"sequence added", func(s *models.Schema) { s.Sequences = []*models.Sequence{models.InitSequence("sq", "public")} },
|
||||
func(c *SchemaChange) bool { return c.Sequences != nil && len(c.Sequences.Extra) == 1 },
|
||||
},
|
||||
{
|
||||
"script added", func(s *models.Schema) { s.Scripts = []*models.Script{models.InitScript("sc")} },
|
||||
func(c *SchemaChange) bool { return c.Scripts != nil && len(c.Scripts.Extra) == 1 },
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
target := mk()
|
||||
tt.mutate(target)
|
||||
got := compareSchemaDetails(mk(), target)
|
||||
if got == nil || got.Name != "public" || !tt.check(got) {
|
||||
t.Errorf("unexpected change: %+v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompareConstraintDetails(t *testing.T) {
|
||||
base := func() *models.Constraint {
|
||||
c := models.InitConstraint("fk", models.ForeignKeyConstraint)
|
||||
c.Columns = []string{"a"}
|
||||
c.ReferencedTable = "users"
|
||||
c.ReferencedColumns = []string{"id"}
|
||||
c.OnDelete = "CASCADE"
|
||||
c.OnUpdate = "NO ACTION"
|
||||
return c
|
||||
}
|
||||
if got := compareConstraintDetails(base(), base()); len(got) != 0 {
|
||||
t.Errorf("identical: %v", got)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
mutate func(*models.Constraint)
|
||||
wantKey string
|
||||
}{
|
||||
{"type", func(c *models.Constraint) { c.Type = models.UniqueConstraint }, "type"},
|
||||
{"columns", func(c *models.Constraint) { c.Columns = []string{"b"} }, "columns"},
|
||||
{"referenced table", func(c *models.Constraint) { c.ReferencedTable = "other" }, "referenced_table"},
|
||||
{"referenced columns", func(c *models.Constraint) { c.ReferencedColumns = []string{"x"} }, "referenced_columns"},
|
||||
{"on delete", func(c *models.Constraint) { c.OnDelete = "SET NULL" }, "on_delete"},
|
||||
{"on update", func(c *models.Constraint) { c.OnUpdate = "CASCADE" }, "on_update"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
target := base()
|
||||
tt.mutate(target)
|
||||
got := compareConstraintDetails(base(), target)
|
||||
if _, ok := got[tt.wantKey]; !ok || len(got) != 1 {
|
||||
t.Errorf("got %v, want only %q", got, tt.wantKey)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Action spelling variants that mean the same thing are not changes.
|
||||
a, b := base(), base()
|
||||
a.OnDelete, b.OnDelete = "cascade", " CASCADE "
|
||||
a.OnUpdate, b.OnUpdate = "", "no action"
|
||||
if got := compareConstraintDetails(a, b); len(got) != 0 {
|
||||
t.Errorf("equivalent actions reported as changes: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeConstraintAction(t *testing.T) {
|
||||
tests := []struct{ in, want string }{
|
||||
{"", ""},
|
||||
{"NO ACTION", ""},
|
||||
{"no action", ""},
|
||||
{" No Action ", ""},
|
||||
{"cascade", "CASCADE"},
|
||||
{" set null ", "SET NULL"},
|
||||
{"RESTRICT", "RESTRICT"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := normalizeConstraintAction(tt.in); got != tt.want {
|
||||
t.Errorf("normalizeConstraintAction(%q) = %q, want %q", tt.in, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConstraintCompareKey(t *testing.T) {
|
||||
uq := &models.Constraint{Name: "UQ_Name", Type: models.UniqueConstraint}
|
||||
if got := constraintCompareKey(uq); got != "uq_name" {
|
||||
t.Errorf("non-FK key: %q", got)
|
||||
}
|
||||
fk := func(name string) *models.Constraint {
|
||||
return &models.Constraint{
|
||||
Name: name, Type: models.ForeignKeyConstraint, Schema: "Public", Table: "Orders",
|
||||
Columns: []string{"user_id"}, ReferencedSchema: "Public", ReferencedTable: "Users", ReferencedColumns: []string{"id"},
|
||||
}
|
||||
}
|
||||
if constraintCompareKey(fk("a")) != constraintCompareKey(fk("b")) {
|
||||
t.Error("FK key must ignore the constraint name")
|
||||
}
|
||||
other := fk("a")
|
||||
other.ReferencedColumns = []string{"uid"}
|
||||
if constraintCompareKey(fk("a")) == constraintCompareKey(other) {
|
||||
t.Error("FK key must include referenced columns")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterPrimaryKeyConstraints(t *testing.T) {
|
||||
in := map[string]*models.Constraint{
|
||||
"pk": {Name: "pk", Type: models.PrimaryKeyConstraint},
|
||||
"uq": {Name: "uq", Type: models.UniqueConstraint},
|
||||
"fk": {Name: "fk", Type: models.ForeignKeyConstraint},
|
||||
}
|
||||
got := filterPrimaryKeyConstraints(in)
|
||||
if len(got) != 2 || got["pk"] != nil || got["uq"] == nil || got["fk"] == nil {
|
||||
t.Errorf("got %v", got)
|
||||
}
|
||||
if len(in) != 3 {
|
||||
t.Error("input must not be modified")
|
||||
}
|
||||
if got := filterPrimaryKeyConstraints(nil); got == nil || len(got) != 0 {
|
||||
t.Errorf("nil: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompareRelationshipDetails(t *testing.T) {
|
||||
base := func() *models.Relationship {
|
||||
r := models.InitRelationship("r", models.RelationType("one_to_many"))
|
||||
r.FromTable, r.ToTable = "orders", "users"
|
||||
r.FromColumns, r.ToColumns = []string{"user_id"}, []string{"id"}
|
||||
return r
|
||||
}
|
||||
if got := compareRelationshipDetails(base(), base()); len(got) != 0 {
|
||||
t.Errorf("identical: %v", got)
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
mutate func(*models.Relationship)
|
||||
wantKey string
|
||||
}{
|
||||
{"type", func(r *models.Relationship) { r.Type = "one_to_one" }, "type"},
|
||||
{"from table", func(r *models.Relationship) { r.FromTable = "x" }, "from_table"},
|
||||
{"to table", func(r *models.Relationship) { r.ToTable = "x" }, "to_table"},
|
||||
{"from columns", func(r *models.Relationship) { r.FromColumns = []string{"x"} }, "from_columns"},
|
||||
{"to columns", func(r *models.Relationship) { r.ToColumns = []string{"x"} }, "to_columns"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
target := base()
|
||||
tt.mutate(target)
|
||||
got := compareRelationshipDetails(base(), target)
|
||||
if _, ok := got[tt.wantKey]; !ok || len(got) != 1 {
|
||||
t.Errorf("got %v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompareRelationshipsModified(t *testing.T) {
|
||||
src := map[string]*models.Relationship{
|
||||
"same": {Name: "same", Type: "one_to_many"},
|
||||
"changed": {Name: "changed", Type: "one_to_many"},
|
||||
"missing": {Name: "missing"},
|
||||
}
|
||||
tgt := map[string]*models.Relationship{
|
||||
"same": {Name: "same", Type: "one_to_many"},
|
||||
"changed": {Name: "changed", Type: "many_to_many"},
|
||||
"extra": {Name: "extra"},
|
||||
}
|
||||
d := compareRelationships(src, tgt)
|
||||
if len(d.Missing) != 1 || d.Missing[0].Name != "missing" || len(d.Extra) != 1 || d.Extra[0].Name != "extra" ||
|
||||
len(d.Modified) != 1 || d.Modified[0].Name != "changed" {
|
||||
t.Errorf("got %+v", d)
|
||||
}
|
||||
if _, ok := d.Modified[0].Changes["type"]; !ok {
|
||||
t.Errorf("changes: %v", d.Modified[0].Changes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompareViews(t *testing.T) {
|
||||
v := func(name, def string) *models.View { return &models.View{Name: name, Definition: def} }
|
||||
src := []*models.View{v("Keep", "select 1"), v("Changed", "select 1"), v("Gone", "select 1")}
|
||||
tgt := []*models.View{v("keep", "select 1"), v("changed", "select 2"), v("New", "select 1")}
|
||||
|
||||
d := compareViews(src, tgt)
|
||||
if len(d.Missing) != 1 || d.Missing[0].Name != "Gone" {
|
||||
t.Errorf("missing: %+v", d.Missing)
|
||||
}
|
||||
if len(d.Extra) != 1 || d.Extra[0].Name != "New" {
|
||||
t.Errorf("extra: %+v", d.Extra)
|
||||
}
|
||||
if len(d.Modified) != 1 || d.Modified[0].Name != "changed" || d.Modified[0].Source.Definition != "select 1" || d.Modified[0].Target.Definition != "select 2" {
|
||||
t.Errorf("modified: %+v", d.Modified)
|
||||
}
|
||||
want := map[string]any{"definition": map[string]string{"source": "select 1", "target": "select 2"}}
|
||||
if !reflect.DeepEqual(d.Modified[0].Changes, want) {
|
||||
t.Errorf("changes: %v", d.Modified[0].Changes)
|
||||
}
|
||||
if !isEmpty(compareViews(nil, nil)) {
|
||||
t.Error("nil views must be empty")
|
||||
}
|
||||
if got := compareViewDetails(v("a", "x"), v("a", "x")); len(got) != 0 {
|
||||
t.Errorf("same definition: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompareSequences(t *testing.T) {
|
||||
seq := func(name string, start, inc, min, max int64, cycle bool) *models.Sequence {
|
||||
return &models.Sequence{Name: name, StartValue: start, IncrementBy: inc, MinValue: min, MaxValue: max, Cycle: cycle}
|
||||
}
|
||||
src := []*models.Sequence{seq("Same", 1, 1, 1, 100, false), seq("Diff", 1, 1, 1, 100, false), seq("Gone", 1, 1, 1, 1, false)}
|
||||
tgt := []*models.Sequence{seq("same", 1, 1, 1, 100, false), seq("diff", 5, 2, 3, 200, true), seq("New", 1, 1, 1, 1, false)}
|
||||
|
||||
d := compareSequences(src, tgt)
|
||||
if len(d.Missing) != 1 || d.Missing[0].Name != "Gone" || len(d.Extra) != 1 || d.Extra[0].Name != "New" || len(d.Modified) != 1 {
|
||||
t.Fatalf("got %+v", d)
|
||||
}
|
||||
ch := d.Modified[0].Changes
|
||||
for _, key := range []string{"start_value", "increment_by", "min_value", "max_value", "cycle"} {
|
||||
if _, ok := ch[key]; !ok {
|
||||
t.Errorf("missing change key %q in %v", key, ch)
|
||||
}
|
||||
}
|
||||
if got := ch["increment_by"].(map[string]int64); got["source"] != 1 || got["target"] != 2 {
|
||||
t.Errorf("increment_by: %v", got)
|
||||
}
|
||||
if got := ch["cycle"].(map[string]bool); got["source"] || !got["target"] {
|
||||
t.Errorf("cycle: %v", got)
|
||||
}
|
||||
if got := compareSequenceDetails(seq("a", 1, 1, 1, 1, false), seq("a", 1, 1, 1, 1, false)); len(got) != 0 {
|
||||
t.Errorf("identical: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompareScriptDetailsAllFields(t *testing.T) {
|
||||
a := &models.Script{Name: "s", SQL: "a", Rollback: "ra", RunAfter: []string{"x"}, Schema: "p", Version: "1", Priority: 1, Sequence: 1}
|
||||
b := &models.Script{Name: "s", SQL: "b", Rollback: "rb", RunAfter: []string{"y"}, Schema: "q", Version: "2", Priority: 2, Sequence: 2}
|
||||
got := compareScriptDetails(a, b)
|
||||
for _, key := range []string{"sql", "rollback", "run_after", "schema", "version", "priority", "sequence"} {
|
||||
if _, ok := got[key]; !ok {
|
||||
t.Errorf("missing %q in %v", key, got)
|
||||
}
|
||||
}
|
||||
if got := compareScriptDetails(a, a); len(got) != 0 {
|
||||
t.Errorf("identical: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsEmptyAllTypes(t *testing.T) {
|
||||
if !isEmpty(&ViewDiff{}) || !isEmpty(&SequenceDiff{}) {
|
||||
t.Error("empty view/sequence diffs must be empty")
|
||||
}
|
||||
if isEmpty(&ViewDiff{Extra: []*models.View{{Name: "v"}}}) || isEmpty(&SequenceDiff{Modified: []*SequenceChange{{Name: "s"}}}) {
|
||||
t.Error("non-empty diffs reported as empty")
|
||||
}
|
||||
if isEmpty(&ConstraintDiff{Modified: []*ConstraintChange{{Name: "c"}}}) || isEmpty(&RelationshipDiff{Missing: []*models.Relationship{{Name: "r"}}}) {
|
||||
t.Error("non-empty diffs reported as empty")
|
||||
}
|
||||
if isEmpty(&IndexDiff{Modified: []*IndexChange{{Name: "i"}}}) || isEmpty(&TableDiff{Modified: []*TableChange{{Name: "t"}}}) {
|
||||
t.Error("non-empty diffs reported as empty")
|
||||
}
|
||||
if isEmpty("something else") || isEmpty(nil) {
|
||||
t.Error("unknown types must not be treated as empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeSummaryFullTree(t *testing.T) {
|
||||
res := &DiffResult{Schemas: &SchemaDiff{
|
||||
Missing: []*models.Schema{{Name: "m"}},
|
||||
Extra: []*models.Schema{{Name: "e"}},
|
||||
Modified: []*SchemaChange{{
|
||||
Name: "public",
|
||||
Tables: &TableDiff{
|
||||
Missing: []*models.Table{{Name: "a"}},
|
||||
Extra: []*models.Table{{Name: "b"}, {Name: "c"}},
|
||||
Modified: []*TableChange{{
|
||||
Name: "t",
|
||||
Columns: &ColumnDiff{Missing: []*models.Column{{}}, Extra: []*models.Column{{}, {}}, Modified: []*ColumnChange{{}}},
|
||||
Indexes: &IndexDiff{Missing: []*models.Index{{}}, Extra: []*models.Index{{}}, Modified: []*IndexChange{{}, {}}},
|
||||
Constraints: &ConstraintDiff{Missing: []*models.Constraint{{}}, Modified: []*ConstraintChange{{}}},
|
||||
Relationships: &RelationshipDiff{Extra: []*models.Relationship{{}}},
|
||||
}},
|
||||
},
|
||||
Views: &ViewDiff{Missing: []*models.View{{}}, Extra: []*models.View{{}}, Modified: []*ViewChange{{}}},
|
||||
Sequences: &SequenceDiff{Missing: []*models.Sequence{{}}, Extra: []*models.Sequence{{}, {}}},
|
||||
Scripts: &ScriptDiff{Modified: []*ScriptChange{{}}},
|
||||
}},
|
||||
}}
|
||||
s := ComputeSummary(res)
|
||||
checks := []struct {
|
||||
name string
|
||||
got [3]int
|
||||
want [3]int
|
||||
}{
|
||||
{"schemas", [3]int{s.Schemas.Missing, s.Schemas.Extra, s.Schemas.Modified}, [3]int{1, 1, 1}},
|
||||
{"tables", [3]int{s.Tables.Missing, s.Tables.Extra, s.Tables.Modified}, [3]int{1, 2, 1}},
|
||||
{"columns", [3]int{s.Columns.Missing, s.Columns.Extra, s.Columns.Modified}, [3]int{1, 2, 1}},
|
||||
{"indexes", [3]int{s.Indexes.Missing, s.Indexes.Extra, s.Indexes.Modified}, [3]int{1, 1, 2}},
|
||||
{"constraints", [3]int{s.Constraints.Missing, s.Constraints.Extra, s.Constraints.Modified}, [3]int{1, 0, 1}},
|
||||
{"relationships", [3]int{s.Relationships.Missing, s.Relationships.Extra, s.Relationships.Modified}, [3]int{0, 1, 0}},
|
||||
{"views", [3]int{s.Views.Missing, s.Views.Extra, s.Views.Modified}, [3]int{1, 1, 1}},
|
||||
{"sequences", [3]int{s.Sequences.Missing, s.Sequences.Extra, s.Sequences.Modified}, [3]int{1, 2, 0}},
|
||||
{"scripts", [3]int{s.Scripts.Missing, s.Scripts.Extra, s.Scripts.Modified}, [3]int{0, 0, 1}},
|
||||
}
|
||||
for _, c := range checks {
|
||||
if c.got != c.want {
|
||||
t.Errorf("%s: got %v, want %v", c.name, c.got, c.want)
|
||||
}
|
||||
}
|
||||
|
||||
if got := ComputeSummary(&DiffResult{}); got == nil || got.Schemas != (SchemaSummary{}) {
|
||||
t.Errorf("nil Schemas: %+v", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package diff
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||
)
|
||||
|
||||
func TestCompareSchemaDetails_DescriptionAndOwner(t *testing.T) {
|
||||
mk := func(desc, owner string) *models.Schema {
|
||||
s := models.InitSchema("public")
|
||||
s.Description, s.Owner = desc, owner
|
||||
return s
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
src, tgt *models.Schema
|
||||
wantFields []string
|
||||
}{
|
||||
{"identical", mk("d", "o"), mk("d", "o"), nil},
|
||||
{"description", mk("a", "o"), mk("b", "o"), []string{"description"}},
|
||||
{"owner", mk("d", "x"), mk("d", "y"), []string{"owner"}},
|
||||
{"both", mk("a", "x"), mk("b", "y"), []string{"description", "owner"}},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := compareSchemaDetails(tt.src, tt.tgt)
|
||||
if len(tt.wantFields) == 0 {
|
||||
if got != nil {
|
||||
t.Fatalf("expected no change, got %+v", got)
|
||||
}
|
||||
return
|
||||
}
|
||||
if got == nil || len(got.Changes) != len(tt.wantFields) {
|
||||
t.Fatalf("changes: %+v", got)
|
||||
}
|
||||
for _, f := range tt.wantFields {
|
||||
c, ok := got.Changes[f].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("missing %s: %+v", f, got.Changes)
|
||||
}
|
||||
if c["source"] == c["target"] {
|
||||
t.Errorf("%s source and target equal: %v", f, c)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompareDatabases_SchemaAttrsCounted(t *testing.T) {
|
||||
src, tgt := models.InitDatabase("a"), models.InitDatabase("b")
|
||||
s1, s2 := models.InitSchema("public"), models.InitSchema("public")
|
||||
s1.Owner, s2.Owner = "alice", "bob"
|
||||
src.Schemas, tgt.Schemas = append(src.Schemas, s1), append(tgt.Schemas, s2)
|
||||
|
||||
res := CompareDatabases(src, tgt)
|
||||
if res.Schemas == nil || len(res.Schemas.Modified) != 1 {
|
||||
t.Fatalf("schema owner change not reported: %+v", res.Schemas)
|
||||
}
|
||||
if ComputeSummary(res).Schemas.Modified != 1 {
|
||||
t.Error("summary must count the modified schema")
|
||||
}
|
||||
}
|
||||
+6
-5
@@ -18,11 +18,12 @@ type SchemaDiff struct {
|
||||
|
||||
// SchemaChange represents changes within a schema
|
||||
type SchemaChange struct {
|
||||
Name string `json:"name"`
|
||||
Tables *TableDiff `json:"tables,omitempty"`
|
||||
Views *ViewDiff `json:"views,omitempty"`
|
||||
Sequences *SequenceDiff `json:"sequences,omitempty"`
|
||||
Scripts *ScriptDiff `json:"scripts,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Changes map[string]any `json:"changes,omitempty"` // Schema attributes that differ (description, owner), keyed by field name
|
||||
Tables *TableDiff `json:"tables,omitempty"`
|
||||
Views *ViewDiff `json:"views,omitempty"`
|
||||
Sequences *SequenceDiff `json:"sequences,omitempty"`
|
||||
Scripts *ScriptDiff `json:"scripts,omitempty"`
|
||||
}
|
||||
|
||||
// TableDiff represents differences in tables
|
||||
|
||||
@@ -123,6 +123,7 @@ rules:
|
||||
| `missing_primary_key` | `have_primary_key` | Ensure tables have primary keys |
|
||||
| `orphaned_foreign_key` | `orphaned_foreign_key` | Detect FKs referencing non-existent tables |
|
||||
| `circular_dependency` | `circular_dependency` | Detect circular FK dependencies |
|
||||
| `duplicate_index_name` | `duplicate_index_name` | Index / PK / unique names must be unique per schema (default: `enforce`) |
|
||||
|
||||
## Rule Configuration
|
||||
|
||||
|
||||
@@ -161,6 +161,7 @@ func getValidator(functionName string) (validatorFunc, bool) {
|
||||
"have_primary_key": validateMissingPrimaryKey,
|
||||
"orphaned_foreign_key": validateOrphanedForeignKey,
|
||||
"circular_dependency": validateCircularDependency,
|
||||
"duplicate_index_name": validateDuplicateIndexName,
|
||||
}
|
||||
|
||||
fn, exists := validators[functionName]
|
||||
|
||||
@@ -154,6 +154,11 @@ func GetDefaultConfig() *Config {
|
||||
Function: "circular_dependency",
|
||||
Message: "Circular foreign key dependency detected",
|
||||
},
|
||||
"duplicate_index_name": {
|
||||
Enabled: "enforce",
|
||||
Function: "duplicate_index_name",
|
||||
Message: "Index name is reused within the schema; PostgreSQL skips the duplicate CREATE INDEX IF NOT EXISTS",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ func TestGetDefaultConfig(t *testing.T) {
|
||||
"missing_primary_key",
|
||||
"orphaned_foreign_key",
|
||||
"circular_dependency",
|
||||
"duplicate_index_name",
|
||||
}
|
||||
|
||||
for _, ruleName := range expectedRules {
|
||||
|
||||
@@ -643,3 +643,64 @@ func contains(slice []string, value string) bool {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// validateDuplicateIndexName checks that index names are unique per schema.
|
||||
// PostgreSQL keeps indexes in the schema-wide relation namespace, so a name
|
||||
// reused on another table makes CREATE INDEX IF NOT EXISTS silently skip it.
|
||||
// Primary key and unique constraints create backing indexes and share that
|
||||
// namespace too. An index and a constraint with the same name on the same
|
||||
// table describe one object and are not reported.
|
||||
func validateDuplicateIndexName(db *models.Database, rule Rule, ruleName string) []ValidationResult {
|
||||
results := []ValidationResult{}
|
||||
|
||||
for _, schema := range db.Schemas {
|
||||
// lowercased name -> "table" entries, one per distinct object
|
||||
owners := make(map[string][]string)
|
||||
display := make(map[string]string)
|
||||
order := []string{}
|
||||
|
||||
add := func(name, table string, sameTableMerges bool) {
|
||||
key := strings.ToLower(name)
|
||||
if _, seen := owners[key]; !seen {
|
||||
order = append(order, key)
|
||||
display[key] = name
|
||||
}
|
||||
if sameTableMerges && contains(owners[key], table) {
|
||||
return
|
||||
}
|
||||
owners[key] = append(owners[key], table)
|
||||
}
|
||||
|
||||
for _, table := range schema.Tables {
|
||||
for _, key := range sortedKeys(table.Indexes) {
|
||||
if name := table.Indexes[key].Name; name != "" {
|
||||
add(name, table.Name, false)
|
||||
}
|
||||
}
|
||||
for _, c := range sortConstraints(table.Constraints) {
|
||||
if c.Name == "" || (c.Type != models.PrimaryKeyConstraint && c.Type != models.UniqueConstraint) {
|
||||
continue
|
||||
}
|
||||
add(c.Name, table.Name, true)
|
||||
}
|
||||
}
|
||||
|
||||
for _, key := range order {
|
||||
tables := owners[key]
|
||||
results = append(results, createResult(
|
||||
ruleName,
|
||||
len(tables) == 1,
|
||||
rule.Message,
|
||||
formatLocation(schema.Name, display[key], "")+" on "+strings.Join(tables, ", "),
|
||||
map[string]interface{}{
|
||||
"schema": schema.Name,
|
||||
"index": display[key],
|
||||
"tables": tables,
|
||||
"occurrences": len(tables),
|
||||
},
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
@@ -835,3 +835,84 @@ func TestFormatLocation(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDuplicateIndexName(t *testing.T) {
|
||||
db := &models.Database{
|
||||
Name: "testdb",
|
||||
Schemas: []*models.Schema{
|
||||
{
|
||||
Name: "entity",
|
||||
Tables: []*models.Table{
|
||||
{
|
||||
Name: "actor_phone",
|
||||
Indexes: map[string]*models.Index{
|
||||
"idx_actor": {Name: "idx_actor", Columns: []string{"rid_actor"}},
|
||||
"uk_phone": {Name: "uk_phone", Columns: []string{"phone"}, Unique: true},
|
||||
},
|
||||
Constraints: map[string]*models.Constraint{
|
||||
// Same name as the index on the same table: one object.
|
||||
"uk_phone": {Name: "uk_phone", Type: models.UniqueConstraint, Columns: []string{"phone"}},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "actor_email",
|
||||
Indexes: map[string]*models.Index{
|
||||
"idx_actor": {Name: "idx_actor", Columns: []string{"rid_actor"}},
|
||||
},
|
||||
Constraints: map[string]*models.Constraint{
|
||||
"UK_Phone": {Name: "UK_Phone", Type: models.UniqueConstraint, Columns: []string{"email"}},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "actor_address",
|
||||
Indexes: map[string]*models.Index{
|
||||
"idx_actor": {Name: "idx_actor", Columns: []string{"rid_actor"}},
|
||||
"idx_actor#2": {Name: "idx_actor", Columns: []string{"rid_actor", "kind"}},
|
||||
"idx_address": {Name: "idx_address", Columns: []string{"line1"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
// Same names in another schema do not collide.
|
||||
Name: "org",
|
||||
Tables: []*models.Table{
|
||||
{Name: "api_provider", Indexes: map[string]*models.Index{"idx_actor": {Name: "idx_actor"}}},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
results := validateDuplicateIndexName(db, Rule{Message: "dup"}, "duplicate_index_name")
|
||||
|
||||
got := map[string]bool{}
|
||||
occ := map[string]int{}
|
||||
for _, r := range results {
|
||||
key := r.Context["schema"].(string) + "." + r.Context["index"].(string)
|
||||
got[key] = r.Passed
|
||||
occ[key] = r.Context["occurrences"].(int)
|
||||
}
|
||||
|
||||
want := map[string]struct {
|
||||
passed bool
|
||||
occ int
|
||||
}{
|
||||
"entity.idx_actor": {false, 4},
|
||||
"entity.uk_phone": {false, 2},
|
||||
"entity.idx_address": {true, 1},
|
||||
"org.idx_actor": {true, 1},
|
||||
}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("got %d results %v, want %d", len(got), got, len(want))
|
||||
}
|
||||
for k, w := range want {
|
||||
p, ok := got[k]
|
||||
if !ok {
|
||||
t.Errorf("missing result for %s", k)
|
||||
continue
|
||||
}
|
||||
if p != w.passed || occ[k] != w.occ {
|
||||
t.Errorf("%s: passed=%v occurrences=%d, want passed=%v occurrences=%d", k, p, occ[k], w.passed, w.occ)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+29
-9
@@ -277,15 +277,25 @@ func parseHumanSize(s string) (int64, error) {
|
||||
|
||||
// Options carries the subset of command flags a job file may set.
|
||||
type Options struct {
|
||||
FlattenSchema bool `yaml:"flatten_schema"`
|
||||
Schema string `yaml:"schema"`
|
||||
Package string `yaml:"package"`
|
||||
StrictConversion bool `yaml:"strict_conversion"`
|
||||
LowercaseIdentifiers bool `yaml:"lowercase_identifiers"`
|
||||
FlattenSchema bool `yaml:"flatten_schema"`
|
||||
Schema string `yaml:"schema"`
|
||||
Package string `yaml:"package"`
|
||||
// Types and ArrayNullable mirror the existing convert/split writer flags.
|
||||
// They are passed through only to writers that already support them.
|
||||
Types string `yaml:"types"`
|
||||
ArrayNullable string `yaml:"array_nullable"`
|
||||
ContinueOnError bool `yaml:"continue_on_error"`
|
||||
SkipRelations bool `yaml:"skip_relations"`
|
||||
SkipEnums bool `yaml:"skip_enums"`
|
||||
SkipViews bool `yaml:"skip_views"`
|
||||
SkipDomains bool `yaml:"skip_domains"`
|
||||
SkipSequences bool `yaml:"skip_sequences"`
|
||||
AtomicExecution bool `yaml:"atomic_execution"`
|
||||
// FullDDL makes direct pgsql output execute the full idempotent DDL instead of
|
||||
// diffing against the live database first.
|
||||
FullDDL bool `yaml:"full_ddl"`
|
||||
SkipRelations bool `yaml:"skip_relations"`
|
||||
SkipEnums bool `yaml:"skip_enums"`
|
||||
SkipViews bool `yaml:"skip_views"`
|
||||
SkipDomains bool `yaml:"skip_domains"`
|
||||
SkipSequences bool `yaml:"skip_sequences"`
|
||||
}
|
||||
|
||||
// Dir returns the directory that a job's relative paths resolve against:
|
||||
@@ -472,6 +482,14 @@ func (s *Set) Validate() error {
|
||||
|
||||
func (j *Job) validate() []string {
|
||||
var e []string
|
||||
if j.Options.AtomicExecution {
|
||||
if j.Options.ContinueOnError {
|
||||
e = append(e, "atomic_execution cannot be combined with continue_on_error")
|
||||
}
|
||||
if (j.Command != CommandConvert && j.Command != CommandMerge) || j.Output == nil || j.Output.ConnEnv == "" || j.Output.Format != "pgsql" {
|
||||
e = append(e, "atomic_execution requires convert/merge with pgsql database output")
|
||||
}
|
||||
}
|
||||
|
||||
switch j.Command {
|
||||
case CommandConvert, CommandMerge, CommandScriptsList, CommandScriptsExec,
|
||||
@@ -569,7 +587,9 @@ func (j *Job) validate() []string {
|
||||
e = append(e, "command \"templ\" does not support database output")
|
||||
}
|
||||
if j.Output != nil && j.Output.Format != "" {
|
||||
e = append(e, "output.format is not valid for command \"templ\"")
|
||||
if !strings.EqualFold(j.Output.Format, "text") {
|
||||
e = append(e, "command \"templ\" accepts only output.format: text")
|
||||
}
|
||||
}
|
||||
case CommandSplit:
|
||||
if len(j.Inputs) < 1 {
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
package jobs
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// validateYAML loads one job file and returns the Validate error text ("" when valid).
|
||||
func validateYAML(t *testing.T, body string) string {
|
||||
t.Helper()
|
||||
set := loadOne(t, "version: 1\njobs:\n"+body)
|
||||
if err := set.Validate(); err != nil {
|
||||
return err.Error()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func TestValidateJobTable(t *testing.T) {
|
||||
in := " inputs:\n - path: a.dbml\n format: dbml\n"
|
||||
out := " output:\n format: json\n path: out.json\n"
|
||||
tests := []struct {
|
||||
name string
|
||||
job string
|
||||
want string // substring of the error, "" for valid
|
||||
}{
|
||||
{"missing command", " x:\n description: d\n", "missing command"},
|
||||
{"convert valid", " x:\n command: convert\n" + in + out, ""},
|
||||
{"convert script dirs", " x:\n command: convert\n script_dirs: [s]\n" + in + out, "script_dirs is not valid"},
|
||||
{"convert missing output", " x:\n command: convert\n" + in, "missing output"},
|
||||
{"convert output missing format", " x:\n command: convert\n" + in + " output:\n path: o\n", "output: missing format"},
|
||||
{"convert output unsupported format", " x:\n command: convert\n" + in + " output:\n format: nope\n path: o\n", "unsupported output format"},
|
||||
{"convert output missing path", " x:\n command: convert\n" + in + " output:\n format: json\n", "output: missing path"},
|
||||
{"convert output conn_env on non-exec format", " x:\n command: convert\n" + in + " output:\n format: json\n conn_env: DB\n", "not supported for format"},
|
||||
{"convert output path and conn_env", " x:\n command: convert\n" + in + " output:\n format: pgsql\n conn_env: DB\n path: o.sql\n", "either path or conn_env"},
|
||||
{"convert output conn_env ok", " x:\n command: convert\n" + in + " output:\n format: pgsql\n conn_env: DB\n", ""},
|
||||
{"output secret conn_env", " x:\n command: convert\n" + in + " output:\n format: pgsql\n conn_env: postgres://u:p@h/db\n", "environment variable name"},
|
||||
{"merge needs two inputs", " x:\n command: merge\n" + in + out, "at least 2 input"},
|
||||
{"input missing format", " x:\n command: convert\n inputs:\n - path: a\n" + out, "missing format"},
|
||||
{"input unsupported format", " x:\n command: convert\n inputs:\n - path: a\n format: nope\n" + out, "unsupported input format"},
|
||||
{"input file missing path", " x:\n command: convert\n inputs:\n - format: dbml\n" + out, "missing path"},
|
||||
{"input file with conn_env", " x:\n command: convert\n inputs:\n - path: a\n format: dbml\n conn_env: DB\n" + out, "does not use conn_env"},
|
||||
{"input db missing conn_env", " x:\n command: convert\n inputs:\n - format: pgsql\n" + out, "requires conn_env"},
|
||||
{"input db with path", " x:\n command: convert\n inputs:\n - format: pgsql\n conn_env: DB\n path: a\n" + out, "takes conn_env, not path"},
|
||||
{"input db ok", " x:\n command: convert\n inputs:\n - format: pgsql\n conn_env: DB\n" + out, ""},
|
||||
{"input secret conn_env", " x:\n command: convert\n inputs:\n - format: pgsql\n conn_env: \"host=h password=p\"\n" + out, "environment variable name"},
|
||||
{"bad log size", " x:\n command: convert\n log_max_size: lots\n" + in + out, "log_max_size"},
|
||||
{"absolute logfile", " x:\n command: convert\n logfile: /var/log/x.log\n" + in + out, "absolute paths"},
|
||||
{"home path", " x:\n command: convert\n template: ~/t\n" + in + out, "home-relative"},
|
||||
{"report path traversal", " x:\n command: inspect\n" + in + " report:\n format: json\n path: ../r.json\n", "escapes"},
|
||||
{"script_dir traversal", " x:\n command: scripts-list\n script_dirs: [../x]\n", "escapes"},
|
||||
|
||||
{"templ valid", " x:\n command: templ\n" + in + " template: t.tmpl\n mode: table\n output:\n format: text\n path: o\n", ""},
|
||||
{"templ pgsql input valid", " x:\n command: templ\n inputs:\n - format: pgsql\n conn_env: DB\n template: t.tmpl\n", ""},
|
||||
{"templ no inputs", " x:\n command: templ\n template: t.tmpl\n", "at least 1 input"},
|
||||
{"templ no template", " x:\n command: templ\n" + in, "requires template"},
|
||||
{"templ bad mode", " x:\n command: templ\n" + in + " template: t\n mode: weird\n", "unsupported mode"},
|
||||
{"templ script dirs", " x:\n command: templ\n" + in + " template: t\n script_dirs: [s]\n", "script_dirs is not valid"},
|
||||
{"templ db output", " x:\n command: templ\n" + in + " template: t\n output:\n conn_env: DB\n", "does not support database output"},
|
||||
{"templ non-text output", " x:\n command: templ\n" + in + " template: t\n output:\n format: json\n path: o\n", "only output.format: text"},
|
||||
{"templ input missing format", " x:\n command: templ\n inputs:\n - path: a\n template: t\n", "missing format"},
|
||||
{"templ pgsql input without conn_env", " x:\n command: templ\n inputs:\n - format: pgsql\n template: t\n", "requires conn_env"},
|
||||
{"templ pgsql input with path", " x:\n command: templ\n inputs:\n - format: pgsql\n conn_env: DB\n path: a\n template: t\n", "takes conn_env, not path"},
|
||||
{"templ file input without path", " x:\n command: templ\n inputs:\n - format: dbml\n template: t\n", "missing path"},
|
||||
{"templ file input with conn_env", " x:\n command: templ\n inputs:\n - path: a\n format: dbml\n conn_env: DB\n template: t\n", "does not use conn_env"},
|
||||
{"templ unsupported input format", " x:\n command: templ\n inputs:\n - path: a\n format: nope\n template: t\n", "unsupported templ input format"},
|
||||
{"templ secret conn_env", " x:\n command: templ\n inputs:\n - format: pgsql\n conn_env: a/b\n template: t\n", "environment variable name"},
|
||||
|
||||
{"split needs input", " x:\n command: split\n" + out, "at least 1 input"},
|
||||
{"split script dirs", " x:\n command: split\n" + in + " script_dirs: [s]\n" + out, "script_dirs is not valid"},
|
||||
{"split report", " x:\n command: split\n" + in + " report:\n format: json\n path: r\n" + out, "report is not valid"},
|
||||
{"split db output", " x:\n command: split\n" + in + " output:\n format: pgsql\n conn_env: DB\n", "writes a file"},
|
||||
|
||||
{"inspect script dirs", " x:\n command: inspect\n" + in + " script_dirs: [s]\n report:\n path: r\n", "script_dirs is not valid"},
|
||||
{"inspect output", " x:\n command: inspect\n" + in + out + " report:\n path: r\n", "output is not valid"},
|
||||
{"inspect bad report format", " x:\n command: inspect\n" + in + " report:\n format: html\n path: r\n", "not supported"},
|
||||
{"inspect report without path", " x:\n command: inspect\n" + in + " report:\n format: json\n", "requires report.path"},
|
||||
{"inspect default format ok", " x:\n command: inspect\n" + in + " report:\n path: r.md\n", ""},
|
||||
{"diff summary without path ok", " x:\n command: diff\n" + in + " - path: b.dbml\n format: dbml\n report:\n format: summary\n", ""},
|
||||
{"diff json needs path", " x:\n command: diff\n" + in + " - path: b.dbml\n format: dbml\n report:\n format: json\n", "requires report.path"},
|
||||
{"diff output", " x:\n command: diff\n" + in + " - path: b.dbml\n format: dbml\n" + out + " report:\n format: summary\n", "output is not valid"},
|
||||
{"diff script dirs", " x:\n command: diff\n" + in + " - path: b.dbml\n format: dbml\n script_dirs: [s]\n report:\n format: summary\n", "script_dirs is not valid"},
|
||||
{"diff no report", " x:\n command: diff\n" + in + " - path: b.dbml\n format: dbml\n", "requires a report block"},
|
||||
|
||||
{"scripts-list inputs", " x:\n command: scripts-list\n script_dirs: [s]\n" + in, "inputs is not valid"},
|
||||
{"scripts-list output", " x:\n command: scripts-list\n script_dirs: [s]\n" + out, "output is not valid"},
|
||||
{"scripts-exec inputs", " x:\n command: scripts-exec\n script_dirs: [s]\n" + in + " output:\n conn_env: DB\n", "inputs is not valid"},
|
||||
{"scripts-exec report", " x:\n command: scripts-exec\n script_dirs: [s]\n report:\n path: r\n output:\n conn_env: DB\n", "report is not valid"},
|
||||
{"scripts-exec output path", " x:\n command: scripts-exec\n script_dirs: [s]\n output:\n conn_env: DB\n path: p\n", "output.path is not supported"},
|
||||
{"scripts-exec non-pgsql", " x:\n command: scripts-exec\n script_dirs: [s]\n output:\n conn_env: DB\n format: mssql\n", "only supports pgsql"},
|
||||
{"scripts-exec secret conn_env", " x:\n command: scripts-exec\n script_dirs: [s]\n output:\n conn_env: \"postgres://u@h/d\"\n", "environment variable name"},
|
||||
{"scripts-exec no script dirs", " x:\n command: scripts-exec\n output:\n conn_env: DB\n", "requires at least one script_dir"},
|
||||
{"scripts-exec pgsql format ok", " x:\n command: scripts-exec\n script_dirs: [s]\n output:\n conn_env: DB\n format: pgsql\n", ""},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := validateYAML(t, tt.job)
|
||||
if tt.want == "" {
|
||||
if got != "" {
|
||||
t.Errorf("expected valid, got: %s", got)
|
||||
}
|
||||
return
|
||||
}
|
||||
if !strings.Contains(got, tt.want) {
|
||||
t.Errorf("error %q does not contain %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFromJobInputShape(t *testing.T) {
|
||||
producer := " p:\n command: convert\n inputs:\n - path: a.dbml\n format: dbml\n output:\n format: json\n path: out.json\n"
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"path", " - from_job: p\n path: x\n", "takes no path"},
|
||||
{"format", " - from_job: p\n format: json\n", "drop format"},
|
||||
{"conn_env", " - from_job: p\n conn_env: DB\n", "takes no conn_env"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
for _, cmd := range []string{"convert", "templ"} {
|
||||
t.Run(cmd+"/"+tt.name, func(t *testing.T) {
|
||||
extra := " output:\n format: json\n path: o.json\n"
|
||||
if cmd == "templ" {
|
||||
extra = " template: t.tmpl\n"
|
||||
}
|
||||
got := validateYAML(t, producer+" c:\n command: "+cmd+"\n inputs:\n"+tt.input+extra)
|
||||
if !strings.Contains(got, tt.want) {
|
||||
t.Errorf("error %q does not contain %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvedLogPolicy(t *testing.T) {
|
||||
keep2 := 2
|
||||
keep0 := 0
|
||||
tests := []struct {
|
||||
name string
|
||||
job Job
|
||||
want LogPolicy
|
||||
}{
|
||||
{"built-in defaults", Job{}, LogPolicy{MaxSizeBytes: defaultLogMaxSizeBytes, Keep: defaultLogKeep}},
|
||||
{"file defaults", Job{fileDefaults: &Defaults{LogMaxSize: "1MB", LogKeep: 7}}, LogPolicy{MaxSizeBytes: 1 << 20, Keep: 7}},
|
||||
{"file defaults invalid size falls back", Job{fileDefaults: &Defaults{LogMaxSize: "junk", LogKeep: 0}}, LogPolicy{MaxSizeBytes: defaultLogMaxSizeBytes, Keep: defaultLogKeep}},
|
||||
{"job overrides file", Job{fileDefaults: &Defaults{LogMaxSize: "1MB", LogKeep: 7}, LogMaxSize: "2kb", LogKeep: &keep2}, LogPolicy{MaxSizeBytes: 2 << 10, Keep: 2}},
|
||||
{"job keep zero is honoured", Job{LogKeep: &keep0}, LogPolicy{MaxSizeBytes: defaultLogMaxSizeBytes, Keep: 0}},
|
||||
{"job invalid size ignored", Job{LogMaxSize: "junk"}, LogPolicy{MaxSizeBytes: defaultLogMaxSizeBytes, Keep: defaultLogKeep}},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := tt.job.ResolvedLogPolicy(); got != tt.want {
|
||||
t.Errorf("got %+v, want %+v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadAppliesFileDefaultsAndDir(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "relspec.yml")
|
||||
write(t, p, "version: 1\ndefaults:\n log_max_size: 1MB\n log_keep: 9\n"+"jobs:\n a:\n command: convert\n inputs:\n - path: a.dbml\n format: dbml\n output:\n format: json\n path: o.json\n")
|
||||
set, err := Load([]string{p})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
job := set.Jobs["a"]
|
||||
if job.Dir() != dir {
|
||||
t.Errorf("Dir = %q, want %q", job.Dir(), dir)
|
||||
}
|
||||
if pol := job.ResolvedLogPolicy(); pol.MaxSizeBytes != 1<<20 || pol.Keep != 9 {
|
||||
t.Errorf("policy %+v", pol)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetNamesSorted(t *testing.T) {
|
||||
set := &Set{Jobs: map[string]*Job{"b": {}, "a": {}, "c": {}}}
|
||||
if got := strings.Join(set.Names(), ","); got != "a,b,c" {
|
||||
t.Errorf("got %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanErrors(t *testing.T) {
|
||||
set := loadOne(t, "version: 1\njobs:\n"+
|
||||
" a:\n command: convert\n depends_on: [ghost]\n inputs:\n - path: a.dbml\n format: dbml\n output:\n format: json\n path: o.json\n"+
|
||||
" b:\n command: convert\n inputs:\n - path: a.dbml\n format: dbml\n output:\n format: json\n path: o2.json\n")
|
||||
|
||||
if _, err := set.Plan("nope", true); err == nil || !strings.Contains(err.Error(), "unknown job") || !strings.Contains(err.Error(), "a, b") {
|
||||
t.Errorf("unknown job: %v", err)
|
||||
}
|
||||
if _, err := set.Plan("a", true); err == nil || !strings.Contains(err.Error(), "unknown job \"ghost\"") {
|
||||
t.Errorf("unknown dependency: %v", err)
|
||||
}
|
||||
// Without dependencies the declared dependency is not walked.
|
||||
if got, err := set.Plan("a", false); err != nil || len(got) != 1 || got[0].Name != "a" {
|
||||
t.Errorf("no-deps plan: %v %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanCycleAtRuntime(t *testing.T) {
|
||||
set := &Set{Jobs: map[string]*Job{
|
||||
"a": {Name: "a", DependsOn: []string{"b"}},
|
||||
"b": {Name: "b", DependsOn: []string{"a"}},
|
||||
}}
|
||||
if _, err := set.Plan("a", true); err == nil || !strings.Contains(err.Error(), "cycle") {
|
||||
t.Errorf("want cycle error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscoverErrorsAndFiltering(t *testing.T) {
|
||||
if _, err := Discover(filepath.Join(t.TempDir(), "missing")); err == nil {
|
||||
t.Error("missing dir must fail")
|
||||
}
|
||||
dir := t.TempDir()
|
||||
for _, f := range []string{"relspec.yaml", "relspec.b.yml", "relspec.a.yaml", "relspec.txt", "other.yml", "relspec"} {
|
||||
write(t, filepath.Join(dir, f), "")
|
||||
}
|
||||
if err := os.Mkdir(filepath.Join(dir, "relspec.dir.yml"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := Discover(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var names []string
|
||||
for _, p := range got {
|
||||
names = append(names, filepath.Base(p))
|
||||
}
|
||||
if strings.Join(names, ",") != "relspec.yaml,relspec.a.yaml,relspec.b.yml" {
|
||||
t.Errorf("got %v", names)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafeJoinCases(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if got, err := SafeJoin(root, "sub/file.sql"); err != nil || !strings.HasSuffix(got, filepath.Join("sub", "file.sql")) {
|
||||
t.Errorf("nested: %q %v", got, err)
|
||||
}
|
||||
for _, bad := range []string{"", "/etc/passwd", "~/x", "..", "../x", "a/../../x"} {
|
||||
if _, err := SafeJoin(root, bad); err == nil {
|
||||
t.Errorf("SafeJoin(%q) must fail", bad)
|
||||
}
|
||||
}
|
||||
if _, err := SafeJoin(filepath.Join(root, "does", "not", "exist"), "x"); err == nil {
|
||||
t.Error("unresolvable root must fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLooksLikeSecret(t *testing.T) {
|
||||
for in, want := range map[string]bool{
|
||||
"": false, "DB_URL": false, "MY_DB": false,
|
||||
"postgres://u:p@h/db": true, "host=h": true, "a b": true, "a/b": true, "u@h": true, "k:v": true,
|
||||
} {
|
||||
if got := looksLikeSecret(in); got != want {
|
||||
t.Errorf("looksLikeSecret(%q) = %v, want %v", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
+19
-19
@@ -61,6 +61,7 @@ var MariaDBToCanonicalTypes = map[string]string{
|
||||
// CanonicalToMariaDBTypes maps canonical types to MariaDB/MySQL types.
|
||||
var CanonicalToMariaDBTypes = map[string]string{
|
||||
"bool": "TINYINT(1)",
|
||||
"boolean": "TINYINT(1)",
|
||||
"int8": "TINYINT",
|
||||
"int16": "SMALLINT",
|
||||
"int": "INT",
|
||||
@@ -114,20 +115,26 @@ func NormalizeMariaDBType(baseType string) string {
|
||||
// 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])
|
||||
lower := strings.ToLower(strings.TrimSpace(mariadbType))
|
||||
base := lower
|
||||
if idx := strings.IndexAny(base, "( "); idx >= 0 {
|
||||
base = 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
|
||||
canonical, ok := MariaDBToCanonicalTypes[base]
|
||||
if ok {
|
||||
if strings.Contains(lower, "unsigned") || strings.Contains(lower, "zerofill") {
|
||||
switch canonical {
|
||||
case "int8":
|
||||
return "uint8"
|
||||
case "int16":
|
||||
return "uint16"
|
||||
case "int":
|
||||
return "uint"
|
||||
case "int64":
|
||||
return "uint64"
|
||||
}
|
||||
}
|
||||
return canonical
|
||||
}
|
||||
|
||||
return "string"
|
||||
@@ -145,12 +152,5 @@ func ConvertCanonicalToMariaDB(canonicalType string) string {
|
||||
return mariadbType
|
||||
}
|
||||
|
||||
// Prefix fallback
|
||||
for canonical, mariadb := range CanonicalToMariaDBTypes {
|
||||
if strings.HasPrefix(lower, canonical) {
|
||||
return mariadb
|
||||
}
|
||||
}
|
||||
|
||||
return "VARCHAR(255)"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package mariadb
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestIntegerSignedness(t *testing.T) {
|
||||
for _, tc := range []struct{ input, want string }{
|
||||
{"tinyint(3) unsigned", "uint8"}, {"smallint unsigned", "uint16"}, {"mediumint unsigned", "uint"}, {"int unsigned", "uint"}, {"bigint unsigned", "uint64"}, {"bigint zerofill", "uint64"}, {"bigint", "int64"}, {"varchar(32)", "string"},
|
||||
} {
|
||||
if got := ConvertMariaDBToCanonical(tc.input); got != tc.want {
|
||||
t.Errorf("%s: got %s want %s", tc.input, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
+68
-2
@@ -82,6 +82,15 @@ func (r *MergeResult) merge(target, source *models.Database, opts *MergeOptions)
|
||||
} else {
|
||||
// Schema doesn't exist, add it
|
||||
newSchema := cloneSchema(srcSchema)
|
||||
if len(opts.SkipTableNames) > 0 {
|
||||
kept := newSchema.Tables[:0]
|
||||
for _, t := range newSchema.Tables {
|
||||
if !opts.SkipTableNames[strings.ToLower(t.SQLName())] {
|
||||
kept = append(kept, t)
|
||||
}
|
||||
}
|
||||
newSchema.Tables = kept
|
||||
}
|
||||
target.Schemas = append(target.Schemas, newSchema)
|
||||
r.SchemasAdded++
|
||||
}
|
||||
@@ -91,6 +100,45 @@ func (r *MergeResult) merge(target, source *models.Database, opts *MergeOptions)
|
||||
if !opts.SkipDomains {
|
||||
r.mergeDomains(target, source)
|
||||
}
|
||||
|
||||
mergeDatabaseMetadata(target, source)
|
||||
}
|
||||
|
||||
// mergeDatabaseMetadata adds missing metadata keys and unions []string values,
|
||||
// so per-file reader state (e.g. pending DBML commented refs) survives a merge.
|
||||
func mergeDatabaseMetadata(target, source *models.Database) {
|
||||
if len(source.Metadata) == 0 {
|
||||
return
|
||||
}
|
||||
if target.Metadata == nil {
|
||||
target.Metadata = make(map[string]any, len(source.Metadata))
|
||||
}
|
||||
for key, srcVal := range source.Metadata {
|
||||
tgtVal, exists := target.Metadata[key]
|
||||
if !exists {
|
||||
if list, ok := srcVal.([]string); ok {
|
||||
srcVal = append([]string(nil), list...)
|
||||
}
|
||||
target.Metadata[key] = srcVal
|
||||
continue
|
||||
}
|
||||
tgtList, tgtOK := tgtVal.([]string)
|
||||
srcList, srcOK := srcVal.([]string)
|
||||
if !tgtOK || !srcOK {
|
||||
continue
|
||||
}
|
||||
seen := make(map[string]bool, len(tgtList))
|
||||
for _, v := range tgtList {
|
||||
seen[v] = true
|
||||
}
|
||||
for _, v := range srcList {
|
||||
if !seen[v] {
|
||||
tgtList = append(tgtList, v)
|
||||
seen[v] = true
|
||||
}
|
||||
}
|
||||
target.Metadata[key] = tgtList
|
||||
}
|
||||
}
|
||||
|
||||
func (r *MergeResult) mergeSchemaContents(target, source *models.Schema, opts *MergeOptions) {
|
||||
@@ -401,6 +449,8 @@ func cloneTable(table *models.Table) *models.Table {
|
||||
Description: table.Description,
|
||||
Schema: table.Schema,
|
||||
Comment: table.Comment,
|
||||
Tablespace: table.Tablespace,
|
||||
GUID: table.GUID,
|
||||
Sequence: table.Sequence,
|
||||
UpdatedAt: table.UpdatedAt,
|
||||
Columns: make(map[string]*models.Column),
|
||||
@@ -430,6 +480,14 @@ func cloneTable(table *models.Table) *models.Table {
|
||||
newTable.Indexes[idxName] = cloneIndex(index)
|
||||
}
|
||||
|
||||
// Clone relationships
|
||||
if table.Relationships != nil {
|
||||
newTable.Relationships = make(map[string]*models.Relationship, len(table.Relationships))
|
||||
for relName, rel := range table.Relationships {
|
||||
newTable.Relationships[relName] = cloneRelation(rel)
|
||||
}
|
||||
}
|
||||
|
||||
return newTable
|
||||
}
|
||||
|
||||
@@ -547,8 +605,8 @@ func cloneConstraint(constraint *models.Constraint) *models.Constraint {
|
||||
OnDelete: constraint.OnDelete,
|
||||
Expression: constraint.Expression,
|
||||
Name: constraint.Name,
|
||||
Deferrable: constraint.Deferrable,
|
||||
InitiallyDeferred: constraint.InitiallyDeferred,
|
||||
Deferrable: copyBoolPtr(constraint.Deferrable),
|
||||
InitiallyDeferred: copyBoolPtr(constraint.InitiallyDeferred),
|
||||
Sequence: constraint.Sequence,
|
||||
}
|
||||
copy(newConstraint.Columns, constraint.Columns)
|
||||
@@ -765,3 +823,11 @@ func min(a, b int) int {
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func copyBoolPtr(b *bool) *bool {
|
||||
if b == nil {
|
||||
return nil
|
||||
}
|
||||
v := *b
|
||||
return &v
|
||||
}
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
package merge
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||
)
|
||||
|
||||
func TestMergeSequences(t *testing.T) {
|
||||
target := models.InitSchema("public")
|
||||
target.Sequences = []*models.Sequence{{Name: "Existing", StartValue: 1, IncrementBy: 1}}
|
||||
|
||||
source := models.InitSchema("public")
|
||||
source.Sequences = []*models.Sequence{
|
||||
{Name: "existing", StartValue: 100, IncrementBy: 10}, // conflicting: must not overwrite
|
||||
{Name: "fresh", StartValue: 5, IncrementBy: 2, MinValue: 1, MaxValue: 99, CacheSize: 3, Cycle: true, OwnedByTable: "t", OwnedByColumn: "id", Comment: "c", Description: "d"},
|
||||
}
|
||||
|
||||
res := &MergeResult{}
|
||||
res.mergeSequences(target, source)
|
||||
|
||||
if res.SequencesAdded != 1 || len(target.Sequences) != 2 {
|
||||
t.Fatalf("added=%d len=%d", res.SequencesAdded, len(target.Sequences))
|
||||
}
|
||||
if target.Sequences[0].StartValue != 1 || target.Sequences[0].IncrementBy != 1 {
|
||||
t.Errorf("existing sequence was modified: %+v", target.Sequences[0])
|
||||
}
|
||||
added := target.Sequences[1]
|
||||
if added.Name != "fresh" || added.StartValue != 5 || added.IncrementBy != 2 || added.MinValue != 1 || added.MaxValue != 99 ||
|
||||
added.CacheSize != 3 || !added.Cycle || added.OwnedByTable != "t" || added.OwnedByColumn != "id" || added.Comment != "c" || added.Description != "d" {
|
||||
t.Errorf("clone lost fields: %+v", added)
|
||||
}
|
||||
if added == source.Sequences[1] {
|
||||
t.Error("sequence must be cloned, not shared")
|
||||
}
|
||||
source.Sequences[1].StartValue = 777
|
||||
if added.StartValue != 5 {
|
||||
t.Error("clone must be independent of source")
|
||||
}
|
||||
if cloneSequence(nil) != nil {
|
||||
t.Error("cloneSequence(nil) must be nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloneSchemaIsIndependent(t *testing.T) {
|
||||
src := models.InitSchema("public")
|
||||
src.Description, src.Owner, src.Comment, src.Sequence = "d", "o", "c", 4
|
||||
src.Permissions["r"] = "all"
|
||||
src.Metadata["k"] = "v"
|
||||
src.Scripts = []*models.Script{{Name: "s"}}
|
||||
|
||||
tbl := models.InitTable("t", "public")
|
||||
col := models.InitColumn("id", "t", "public")
|
||||
col.Type = "integer"
|
||||
tbl.Columns["id"] = col
|
||||
tbl.Constraints["pk"] = &models.Constraint{Name: "pk", Type: models.PrimaryKeyConstraint, Columns: []string{"id"}}
|
||||
tbl.Indexes["i"] = &models.Index{Name: "i", Columns: []string{"id"}, Include: []string{"x"}}
|
||||
tbl.Metadata["tm"] = 1
|
||||
src.Tables = []*models.Table{tbl}
|
||||
|
||||
v := models.InitView("v", "public")
|
||||
v.Definition = "select 1"
|
||||
v.Columns["c"] = &models.Column{Name: "c"}
|
||||
v.Metadata["vm"] = 1
|
||||
src.Views = []*models.View{v}
|
||||
src.Sequences = []*models.Sequence{{Name: "sq", StartValue: 3}}
|
||||
src.Enums = []*models.Enum{{Name: "e", Values: []string{"a", "b"}}}
|
||||
src.Relations = []*models.Relationship{{Name: "r", FromColumns: []string{"a"}, ToColumns: []string{"b"}, Properties: map[string]string{"p": "q"}}}
|
||||
|
||||
got := cloneSchema(src)
|
||||
if got == src || got.Name != "public" || got.Description != "d" || got.Owner != "o" || got.Comment != "c" || got.Sequence != 4 {
|
||||
t.Fatalf("scalar fields: %+v", got)
|
||||
}
|
||||
if got.Permissions["r"] != "all" || got.Metadata["k"] != "v" || len(got.Scripts) != 1 {
|
||||
t.Errorf("maps/scripts: %+v", got)
|
||||
}
|
||||
if len(got.Tables) != 1 || got.Tables[0] == tbl || got.Tables[0].Columns["id"] == col || got.Tables[0].Columns["id"].Type != "integer" {
|
||||
t.Errorf("tables not deep cloned: %+v", got.Tables)
|
||||
}
|
||||
if len(got.Views) != 1 || got.Views[0] == v || got.Views[0].Definition != "select 1" || got.Views[0].Columns["c"] == v.Columns["c"] || got.Views[0].Metadata["vm"] != 1 {
|
||||
t.Errorf("views not deep cloned: %+v", got.Views)
|
||||
}
|
||||
if len(got.Sequences) != 1 || got.Sequences[0] == src.Sequences[0] || got.Sequences[0].StartValue != 3 {
|
||||
t.Errorf("sequences: %+v", got.Sequences)
|
||||
}
|
||||
if len(got.Enums) != 1 || got.Enums[0] == src.Enums[0] || strings.Join(got.Enums[0].Values, ",") != "a,b" {
|
||||
t.Errorf("enums: %+v", got.Enums)
|
||||
}
|
||||
if len(got.Relations) != 1 || got.Relations[0] == src.Relations[0] || got.Relations[0].Properties["p"] != "q" {
|
||||
t.Errorf("relations: %+v", got.Relations)
|
||||
}
|
||||
|
||||
// Mutating the clone must not touch the source.
|
||||
got.Permissions["r"] = "none"
|
||||
got.Metadata["k"] = "changed"
|
||||
got.Tables[0].Columns["id"].Type = "text"
|
||||
got.Tables[0].Constraints["pk"].Columns[0] = "zzz"
|
||||
got.Tables[0].Indexes["i"].Columns[0] = "zzz"
|
||||
got.Tables[0].Metadata["tm"] = 2
|
||||
got.Enums[0].Values[0] = "zzz"
|
||||
got.Relations[0].FromColumns[0] = "zzz"
|
||||
got.Relations[0].Properties["p"] = "zzz"
|
||||
got.Views[0].Columns["c"].Name = "zzz"
|
||||
if src.Permissions["r"] != "all" || src.Metadata["k"] != "v" || col.Type != "integer" ||
|
||||
tbl.Constraints["pk"].Columns[0] != "id" || tbl.Indexes["i"].Columns[0] != "id" || tbl.Metadata["tm"] != 1 ||
|
||||
src.Enums[0].Values[0] != "a" || src.Relations[0].FromColumns[0] != "a" || src.Relations[0].Properties["p"] != "q" ||
|
||||
v.Columns["c"].Name != "c" {
|
||||
t.Error("clone shares state with the source")
|
||||
}
|
||||
|
||||
if cloneSchema(nil) != nil {
|
||||
t.Error("cloneSchema(nil) must be nil")
|
||||
}
|
||||
bare := cloneSchema(&models.Schema{Name: "bare"})
|
||||
if bare.Permissions != nil || bare.Metadata != nil {
|
||||
t.Errorf("nil maps must stay nil: %+v", bare)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloneNilInputs(t *testing.T) {
|
||||
if cloneTable(nil) != nil || cloneColumn(nil) != nil || cloneConstraint(nil) != nil || cloneIndex(nil) != nil ||
|
||||
cloneView(nil) != nil || cloneEnum(nil) != nil || cloneRelation(nil) != nil || cloneDomain(nil) != nil {
|
||||
t.Error("clone of nil must be nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloneDomainAndRelation(t *testing.T) {
|
||||
d := &models.Domain{Name: "d", Description: "x", Comment: "c", Sequence: 2, Metadata: map[string]any{"k": 1}, Tables: []*models.DomainTable{{TableName: "t", SchemaName: "s"}}}
|
||||
cd := cloneDomain(d)
|
||||
if cd == d || cd.Name != "d" || cd.Description != "x" || cd.Comment != "c" || cd.Sequence != 2 || cd.Metadata["k"] != 1 || len(cd.Tables) != 1 {
|
||||
t.Errorf("domain clone: %+v", cd)
|
||||
}
|
||||
cd.Metadata["k"] = 2
|
||||
if d.Metadata["k"] != 1 {
|
||||
t.Error("domain metadata shared")
|
||||
}
|
||||
|
||||
r := &models.Relationship{Name: "r", Type: "one_to_many", FromTable: "a", FromSchema: "s", ToTable: "b", ToSchema: "s", ForeignKey: "fk", ThroughTable: "l", ThroughSchema: "s", Description: "d", Sequence: 3}
|
||||
cr := cloneRelation(r)
|
||||
if cr == r || cr.Name != "r" || cr.Type != "one_to_many" || cr.FromTable != "a" || cr.ToTable != "b" || cr.ForeignKey != "fk" || cr.ThroughTable != "l" || cr.Description != "d" || cr.Sequence != 3 {
|
||||
t.Errorf("relation clone: %+v", cr)
|
||||
}
|
||||
if cr.Properties != nil {
|
||||
t.Errorf("nil properties must stay nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractTypeParts(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
col models.Column
|
||||
wantType string
|
||||
wantLen, wantPrec, wantScale int
|
||||
}{
|
||||
{"plain", models.Column{Type: "TEXT"}, "text", 0, 0, 0},
|
||||
{"trim and lower", models.Column{Type: " Integer "}, "integer", 0, 0, 0},
|
||||
{"embedded length", models.Column{Type: "varchar(50)"}, "varchar", 50, 0, 0},
|
||||
{"embedded precision and scale", models.Column{Type: "numeric(10,2)"}, "numeric", 0, 10, 2},
|
||||
{"embedded with spaces", models.Column{Type: "numeric( 10 , 2 )"}, "numeric", 0, 10, 2},
|
||||
{"fields win over embedded precision", models.Column{Type: "numeric(10,2)", Precision: 12, Scale: 4}, "numeric", 0, 12, 4},
|
||||
{"fields win over embedded length", models.Column{Type: "varchar(50)", Length: 80}, "varchar", 80, 0, 0},
|
||||
{"precision field blocks embedded length", models.Column{Type: "varchar(50)", Precision: 5}, "varchar", 0, 5, 0},
|
||||
{"non-numeric modifier", models.Column{Type: "varchar(max)"}, "varchar", 0, 0, 0},
|
||||
{"zero modifier", models.Column{Type: "char(0)"}, "char", 0, 0, 0},
|
||||
{"serial sugar", models.Column{Type: "bigserial"}, "bigint", 0, 0, 0},
|
||||
{"smallserial sugar", models.Column{Type: "smallserial"}, "smallint", 0, 0, 0},
|
||||
{"three modifiers ignored", models.Column{Type: "x(1,2,3)"}, "x", 0, 0, 0},
|
||||
{"empty", models.Column{}, "", 0, 0, 0},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
col := tt.col
|
||||
gt, gl, gp, gs := extractTypeParts(&col)
|
||||
if gt != tt.wantType || gl != tt.wantLen || gp != tt.wantPrec || gs != tt.wantScale {
|
||||
t.Errorf("got (%q,%d,%d,%d), want (%q,%d,%d,%d)", gt, gl, gp, gs, tt.wantType, tt.wantLen, tt.wantPrec, tt.wantScale)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestColumnTypeConflict(t *testing.T) {
|
||||
c := func(typ string, l, p, s int) *models.Column {
|
||||
return &models.Column{Type: typ, Length: l, Precision: p, Scale: s}
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
a, b *models.Column
|
||||
want bool
|
||||
}{
|
||||
{"nil target", nil, c("text", 0, 0, 0), false},
|
||||
{"nil source", c("text", 0, 0, 0), nil, false},
|
||||
{"same", c("text", 0, 0, 0), c("TEXT", 0, 0, 0), false},
|
||||
{"different base", c("text", 0, 0, 0), c("integer", 0, 0, 0), true},
|
||||
{"embedded equals field", c("varchar(50)", 0, 0, 0), c("varchar", 50, 0, 0), false},
|
||||
{"different length", c("varchar", 50, 0, 0), c("varchar", 80, 0, 0), true},
|
||||
{"different scale", c("numeric", 0, 10, 2), c("numeric", 0, 10, 3), true},
|
||||
{"serial vs int", c("bigserial", 0, 0, 0), c("bigint", 0, 0, 0), false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := columnTypeConflict(tt.a, tt.b); got != tt.want {
|
||||
t.Errorf("got %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDescribeColumnType(t *testing.T) {
|
||||
tests := []struct {
|
||||
col *models.Column
|
||||
want string
|
||||
}{
|
||||
{nil, ""},
|
||||
{&models.Column{}, ""},
|
||||
{&models.Column{Type: " "}, ""},
|
||||
{&models.Column{Type: "text"}, "text"},
|
||||
{&models.Column{Type: " numeric ", Precision: 10, Scale: 2}, "numeric(10,2)"},
|
||||
{&models.Column{Type: "numeric", Precision: 10}, "numeric(10)"},
|
||||
{&models.Column{Type: "varchar", Length: 50}, "varchar(50)"},
|
||||
{&models.Column{Type: "varchar", Length: 50, Precision: 7}, "varchar(7)"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := describeColumnType(tt.col); got != tt.want {
|
||||
t.Errorf("describeColumnType(%+v) = %q, want %q", tt.col, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFirstNonEmpty(t *testing.T) {
|
||||
if got := firstNonEmpty("", " ", "x", "y"); got != "x" {
|
||||
t.Errorf("got %q", got)
|
||||
}
|
||||
if got := firstNonEmpty(); got != "" {
|
||||
t.Errorf("none: %q", got)
|
||||
}
|
||||
if got := firstNonEmpty("", " "); got != "" {
|
||||
t.Errorf("all blank: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetColumnTypeConflictSummary(t *testing.T) {
|
||||
conflicts := []ColumnTypeConflict{
|
||||
{Schema: "s", Table: "t", Column: "a", TargetType: "text", SourceType: "integer"},
|
||||
{Schema: "s", Table: "t", Column: "b", TargetType: "int", SourceType: "text"},
|
||||
{Schema: "s", Table: "u", Column: "c", TargetType: "x", SourceType: "y"},
|
||||
}
|
||||
res := &MergeResult{TypeConflicts: conflicts}
|
||||
|
||||
if GetColumnTypeConflictSummary(nil, 5) != "" || GetColumnTypeConflictSummary(&MergeResult{}, 5) != "" {
|
||||
t.Error("no conflicts must yield empty summary")
|
||||
}
|
||||
|
||||
all := GetColumnTypeConflictSummary(res, 0)
|
||||
if !strings.Contains(all, "column type conflicts detected:") || !strings.Contains(all, "s.t.a: target=text source=integer") ||
|
||||
!strings.Contains(all, "s.u.c: target=x source=y") || strings.Contains(all, "more") {
|
||||
t.Errorf("unlimited summary:\n%s", all)
|
||||
}
|
||||
if neg := GetColumnTypeConflictSummary(res, -1); neg != all {
|
||||
t.Error("negative limit must behave as unlimited")
|
||||
}
|
||||
|
||||
limited := GetColumnTypeConflictSummary(res, 2)
|
||||
if !strings.Contains(limited, "s.t.b") || strings.Contains(limited, "s.u.c") || !strings.HasSuffix(limited, "... and 1 more") {
|
||||
t.Errorf("limited summary:\n%s", limited)
|
||||
}
|
||||
exact := GetColumnTypeConflictSummary(res, 3)
|
||||
if strings.Contains(exact, "more") {
|
||||
t.Errorf("limit == len must not truncate:\n%s", exact)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMinHelper(t *testing.T) {
|
||||
if min(1, 2) != 1 || min(2, 1) != 1 || min(3, 3) != 3 {
|
||||
t.Error("min")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package merge
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||
)
|
||||
|
||||
func sourceWithRelationship() *models.Database {
|
||||
db := models.InitDatabase("src")
|
||||
s := models.InitSchema("sales")
|
||||
orders := models.InitTable("orders", "sales")
|
||||
orders.Tablespace = "fast"
|
||||
orders.GUID = "guid-1"
|
||||
orders.Relationships["fk_cust"] = &models.Relationship{
|
||||
Name: "fk_cust", FromTable: "orders", ToTable: "customers",
|
||||
FromColumns: []string{"cust_id"}, ToColumns: []string{"id"},
|
||||
}
|
||||
s.Tables = append(s.Tables, orders, models.InitTable("Audit", "sales"))
|
||||
db.Schemas = append(db.Schemas, s)
|
||||
return db
|
||||
}
|
||||
|
||||
func TestCloneTable_CopiesRelationshipsTablespaceGUID(t *testing.T) {
|
||||
src := sourceWithRelationship()
|
||||
target := models.InitDatabase("tgt")
|
||||
MergeDatabases(target, src, nil)
|
||||
|
||||
got := target.Schemas[0].Tables[0]
|
||||
if got.Tablespace != "fast" || got.GUID != "guid-1" {
|
||||
t.Errorf("tablespace/guid lost: %+v", got)
|
||||
}
|
||||
rel := got.Relationships["fk_cust"]
|
||||
if rel == nil || rel.ToTable != "customers" {
|
||||
t.Fatalf("relationship lost: %+v", got.Relationships)
|
||||
}
|
||||
if rel == src.Schemas[0].Tables[0].Relationships["fk_cust"] {
|
||||
t.Error("relationship must be deep-copied")
|
||||
}
|
||||
rel.FromColumns[0] = "changed"
|
||||
if src.Schemas[0].Tables[0].Relationships["fk_cust"].FromColumns[0] != "cust_id" {
|
||||
t.Error("relationship columns shared with source")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMerge_SkipTablesAppliesToNewSchemas(t *testing.T) {
|
||||
src := sourceWithRelationship()
|
||||
target := models.InitDatabase("tgt")
|
||||
MergeDatabases(target, src, &MergeOptions{SkipTableNames: map[string]bool{"audit": true}})
|
||||
|
||||
tables := target.Schemas[0].Tables
|
||||
if len(tables) != 1 || tables[0].Name != "orders" {
|
||||
t.Errorf("skipped table copied into new schema: %+v", tables)
|
||||
}
|
||||
if len(src.Schemas[0].Tables) != 2 {
|
||||
t.Error("source must not be modified")
|
||||
}
|
||||
}
|
||||
@@ -721,3 +721,32 @@ func TestComplexMerge(t *testing.T) {
|
||||
t.Error("Expected ukey_users_guid constraint to exist")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeDatabases_Metadata(t *testing.T) {
|
||||
target := &models.Database{Metadata: map[string]any{
|
||||
"refs": []string{"a", "b"},
|
||||
"name": "target",
|
||||
}}
|
||||
source := &models.Database{Metadata: map[string]any{
|
||||
"refs": []string{"b", "c"},
|
||||
"name": "source",
|
||||
"extra": []string{"x"},
|
||||
}}
|
||||
|
||||
MergeDatabases(target, source, nil)
|
||||
|
||||
if got := target.Metadata["refs"].([]string); strings.Join(got, ",") != "a,b,c" {
|
||||
t.Errorf("refs = %v, want [a b c]", got)
|
||||
}
|
||||
if got := target.Metadata["name"]; got != "target" {
|
||||
t.Errorf("name = %v, want target (existing scalar keys are kept)", got)
|
||||
}
|
||||
extra := target.Metadata["extra"].([]string)
|
||||
if strings.Join(extra, ",") != "x" {
|
||||
t.Errorf("extra = %v, want [x]", extra)
|
||||
}
|
||||
source.Metadata["extra"].([]string)[0] = "changed"
|
||||
if extra[0] != "x" {
|
||||
t.Error("copied slice must not alias the source")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package models
|
||||
|
||||
// DeferralClause returns the SQL deferral clause for a foreign key constraint
|
||||
// ("DEFERRABLE", "DEFERRABLE INITIALLY DEFERRED" or "NOT DEFERRABLE"), or ""
|
||||
// when the model does not specify one. InitiallyDeferred without Deferrable
|
||||
// implies DEFERRABLE; InitiallyDeferred=false alone specifies nothing.
|
||||
func (d *Constraint) DeferralClause() string {
|
||||
deferrable, initially, set := d.effectiveDeferral()
|
||||
switch {
|
||||
case !set:
|
||||
return ""
|
||||
case !deferrable:
|
||||
return "NOT DEFERRABLE"
|
||||
case initially != nil && *initially:
|
||||
return "DEFERRABLE INITIALLY DEFERRED"
|
||||
default:
|
||||
return "DEFERRABLE"
|
||||
}
|
||||
}
|
||||
|
||||
// effectiveDeferral resolves the optional flags. set is false when the model says nothing
|
||||
// about deferral; initially is nil when the initial mode is unspecified.
|
||||
func (d *Constraint) effectiveDeferral() (deferrable bool, initially *bool, set bool) {
|
||||
switch {
|
||||
case d.Deferrable != nil:
|
||||
return *d.Deferrable, d.InitiallyDeferred, true
|
||||
case d.InitiallyDeferred != nil && *d.InitiallyDeferred:
|
||||
return true, d.InitiallyDeferred, true
|
||||
}
|
||||
return false, nil, false
|
||||
}
|
||||
|
||||
// DeferralMatches reports whether two constraints agree on deferral. An unspecified side
|
||||
// matches anything, and the initial mode is only compared when both sides specify it.
|
||||
func DeferralMatches(a, b *Constraint) bool {
|
||||
da, ia, aSet := a.effectiveDeferral()
|
||||
db, ib, bSet := b.effectiveDeferral()
|
||||
if !aSet || !bSet {
|
||||
return true
|
||||
}
|
||||
if da != db {
|
||||
return false
|
||||
}
|
||||
if !da || ia == nil || ib == nil {
|
||||
return true
|
||||
}
|
||||
return *ia == *ib
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package models
|
||||
|
||||
import "testing"
|
||||
|
||||
func boolPtr(b bool) *bool { return &b }
|
||||
|
||||
func TestConstraint_DeferralClause(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
c Constraint
|
||||
want string
|
||||
}{
|
||||
{"unspecified", Constraint{}, ""},
|
||||
{"deferrable", Constraint{Deferrable: boolPtr(true)}, "DEFERRABLE"},
|
||||
{"deferrable initially deferred", Constraint{Deferrable: boolPtr(true), InitiallyDeferred: boolPtr(true)}, "DEFERRABLE INITIALLY DEFERRED"},
|
||||
{"deferrable initially immediate", Constraint{Deferrable: boolPtr(true), InitiallyDeferred: boolPtr(false)}, "DEFERRABLE"},
|
||||
{"initially deferred implies deferrable", Constraint{InitiallyDeferred: boolPtr(true)}, "DEFERRABLE INITIALLY DEFERRED"},
|
||||
{"initially immediate alone is unspecified", Constraint{InitiallyDeferred: boolPtr(false)}, ""},
|
||||
{"explicitly not deferrable", Constraint{Deferrable: boolPtr(false)}, "NOT DEFERRABLE"},
|
||||
{"not deferrable ignores initially deferred", Constraint{Deferrable: boolPtr(false), InitiallyDeferred: boolPtr(true)}, "NOT DEFERRABLE"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := tt.c.DeferralClause(); got != tt.want {
|
||||
t.Errorf("DeferralClause() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeferralMatches(t *testing.T) {
|
||||
live := func(def, init bool) *Constraint {
|
||||
return &Constraint{Deferrable: boolPtr(def), InitiallyDeferred: boolPtr(init)}
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
model, other *Constraint
|
||||
want bool
|
||||
}{
|
||||
{"model unspecified vs deferrable live", &Constraint{}, live(true, false), true},
|
||||
{"model unspecified vs not deferrable live", &Constraint{}, live(false, false), true},
|
||||
{"deferrable vs deferrable live", &Constraint{Deferrable: boolPtr(true)}, live(true, true), true},
|
||||
{"deferrable vs not deferrable live", &Constraint{Deferrable: boolPtr(true)}, live(false, false), false},
|
||||
{"not deferrable vs deferrable live", &Constraint{Deferrable: boolPtr(false)}, live(true, false), false},
|
||||
{"initially deferred vs initially immediate live", &Constraint{InitiallyDeferred: boolPtr(true)}, live(true, false), false},
|
||||
{"initially deferred vs initially deferred live", &Constraint{InitiallyDeferred: boolPtr(true)}, live(true, true), true},
|
||||
{"explicit initially immediate vs initially deferred live", &Constraint{Deferrable: boolPtr(true), InitiallyDeferred: boolPtr(false)}, live(true, true), false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := DeferralMatches(tt.model, tt.other); got != tt.want {
|
||||
t.Errorf("DeferralMatches() = %v, want %v", got, tt.want)
|
||||
}
|
||||
if got := DeferralMatches(tt.other, tt.model); got != tt.want {
|
||||
t.Errorf("DeferralMatches() reversed = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+14
-2
@@ -20,6 +20,7 @@ const (
|
||||
PostgresqlDatabaseType DatabaseType = "pgsql" // PostgreSQL database
|
||||
MSSQLDatabaseType DatabaseType = "mssql" // Microsoft SQL Server database
|
||||
SqlLiteDatabaseType DatabaseType = "sqlite" // SQLite database
|
||||
MySQLDatabaseType DatabaseType = "mysql" // MySQL/MariaDB database
|
||||
)
|
||||
|
||||
// Database represents the complete database schema
|
||||
@@ -255,9 +256,19 @@ func (d *Column) SQLName() string {
|
||||
return strings.ToLower(d.Name)
|
||||
}
|
||||
|
||||
// IndexPart is an ordered MySQL index key part. Name and Expression are mutually
|
||||
// exclusive; expressions are executable SQL and are not identifier-normalized.
|
||||
type IndexPart struct {
|
||||
Name string `json:"name,omitempty" yaml:"name,omitempty" xml:"name,omitempty"`
|
||||
Expression string `json:"expression,omitempty" yaml:"expression,omitempty" xml:"expression,omitempty"`
|
||||
Length int `json:"length,omitempty" yaml:"length,omitempty" xml:"length,omitempty"`
|
||||
Descending bool `json:"descending,omitempty" yaml:"descending,omitempty" xml:"descending,omitempty"`
|
||||
}
|
||||
|
||||
// Index represents a database index for optimizing query performance.
|
||||
// Indexes can be unique, partial, or include additional columns.
|
||||
type Index struct {
|
||||
KeyParts []IndexPart `json:"key_parts,omitempty" yaml:"key_parts,omitempty" xml:"key_parts,omitempty"`
|
||||
Name string `json:"name" yaml:"name" xml:"name"`
|
||||
Description string `json:"description,omitempty" yaml:"description,omitempty" xml:"description,omitempty"`
|
||||
Table string `json:"table,omitempty" yaml:"table,omitempty" xml:"table,omitempty"`
|
||||
@@ -317,6 +328,7 @@ func (d *Relationship) SQLName() string {
|
||||
// Constraint represents a database constraint that enforces data integrity rules.
|
||||
// Constraints can be primary keys, foreign keys, unique constraints, check constraints, or not-null constraints.
|
||||
type Constraint struct {
|
||||
Metadata map[string]any `json:"metadata,omitempty" yaml:"metadata,omitempty" xml:"-"`
|
||||
Name string `json:"name" yaml:"name" xml:"name"`
|
||||
Type ConstraintType `json:"type" yaml:"type" xml:"type"`
|
||||
Columns []string `json:"columns" yaml:"columns" xml:"columns"`
|
||||
@@ -328,8 +340,8 @@ type Constraint struct {
|
||||
ReferencedColumns []string `json:"referenced_columns" yaml:"referenced_columns" xml:"referenced_columns"`
|
||||
OnDelete string `json:"on_delete" yaml:"on_delete" xml:"on_delete"` // CASCADE, SET NULL, RESTRICT, etc.
|
||||
OnUpdate string `json:"on_update" yaml:"on_update" xml:"on_update"`
|
||||
Deferrable bool `json:"deferrable,omitempty" yaml:"deferrable,omitempty" xml:"deferrable,omitempty"`
|
||||
InitiallyDeferred bool `json:"initially_deferred,omitempty" yaml:"initially_deferred,omitempty" xml:"initially_deferred,omitempty"`
|
||||
Deferrable *bool `json:"deferrable,omitempty" yaml:"deferrable,omitempty" xml:"deferrable,omitempty"` // nil: unspecified, left as is
|
||||
InitiallyDeferred *bool `json:"initially_deferred,omitempty" yaml:"initially_deferred,omitempty" xml:"initially_deferred,omitempty"` // nil: unspecified, left as is
|
||||
Sequence uint `json:"sequence,omitempty" yaml:"sequence,omitempty" xml:"sequence,omitempty"`
|
||||
GUID string `json:"guid" yaml:"guid" xml:"guid"`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSQLNameLowercases(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
got string
|
||||
}{
|
||||
{"database", (&Database{Name: "MyDB"}).SQLName()},
|
||||
{"domain", (&Domain{Name: "MyDomain"}).SQLName()},
|
||||
{"schema", (&Schema{Name: "MySchema"}).SQLName()},
|
||||
{"table", (&Table{Name: "MyTable"}).SQLName()},
|
||||
{"view", (&View{Name: "MyView"}).SQLName()},
|
||||
{"sequence", (&Sequence{Name: "MySeq"}).SQLName()},
|
||||
{"column", (&Column{Name: "MyCol"}).SQLName()},
|
||||
{"index", (&Index{Name: "MyIdx"}).SQLName()},
|
||||
{"relationship", (&Relationship{Name: "MyRel"}).SQLName()},
|
||||
{"constraint", (&Constraint{Name: "MyCon"}).SQLName()},
|
||||
{"enum", (&Enum{Name: "MyEnum"}).SQLName()},
|
||||
{"script", (&Script{Name: "MyScript"}).SQLName()},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if tt.got == "" || tt.got != lower(tt.got) {
|
||||
t.Errorf("SQLName not lowercase: %q", tt.got)
|
||||
}
|
||||
})
|
||||
}
|
||||
if got := (&Table{}).SQLName(); got != "" {
|
||||
t.Errorf("empty name: %q", got)
|
||||
}
|
||||
if got := (&Table{Name: "MyTable"}).SQLName(); got != "mytable" {
|
||||
t.Errorf("got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func lower(s string) string {
|
||||
b := []byte(s)
|
||||
for i, c := range b {
|
||||
if c >= 'A' && c <= 'Z' {
|
||||
b[i] = c + 32
|
||||
}
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func TestUpdateDatePropagates(t *testing.T) {
|
||||
db := InitDatabase("d")
|
||||
schema := InitSchema("s")
|
||||
schema.RefDatabase = db
|
||||
table := InitTable("t", "s")
|
||||
table.RefSchema = schema
|
||||
|
||||
table.UpdateDate()
|
||||
for name, v := range map[string]string{"table": table.UpdatedAt, "schema": schema.UpdatedAt, "database": db.UpdatedAt} {
|
||||
ts, err := time.Parse(time.RFC3339, v)
|
||||
if err != nil {
|
||||
t.Fatalf("%s UpdatedAt %q: %v", name, v, err)
|
||||
}
|
||||
if time.Since(ts) > time.Minute {
|
||||
t.Errorf("%s UpdatedAt too old: %v", name, ts)
|
||||
}
|
||||
}
|
||||
|
||||
// Without references only the receiver is updated.
|
||||
lone := InitTable("lone", "s")
|
||||
lone.UpdateDate()
|
||||
if lone.UpdatedAt == "" {
|
||||
t.Error("lone table not updated")
|
||||
}
|
||||
loneSchema := InitSchema("x")
|
||||
loneSchema.UpdateDate()
|
||||
if loneSchema.UpdatedAt == "" {
|
||||
t.Error("lone schema not updated")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPrimaryKey(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cols []*Column
|
||||
want string
|
||||
}{
|
||||
{"none", []*Column{{Name: "a"}}, ""},
|
||||
{"single", []*Column{{Name: "a"}, {Name: "id", IsPrimaryKey: true}}, "id"},
|
||||
{"composite ordered by sequence", []*Column{
|
||||
{Name: "a", IsPrimaryKey: true, Sequence: 2},
|
||||
{Name: "b", IsPrimaryKey: true, Sequence: 1},
|
||||
}, "b"},
|
||||
{"composite without sequence falls back to name", []*Column{
|
||||
{Name: "z", IsPrimaryKey: true},
|
||||
{Name: "m", IsPrimaryKey: true},
|
||||
}, "m"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
tbl := InitTable("t", "s")
|
||||
for _, c := range tt.cols {
|
||||
tbl.Columns[c.Name] = c
|
||||
}
|
||||
got := tbl.GetPrimaryKey()
|
||||
if tt.want == "" {
|
||||
if got != nil {
|
||||
t.Errorf("expected nil, got %s", got.Name)
|
||||
}
|
||||
return
|
||||
}
|
||||
if got == nil || got.Name != tt.want {
|
||||
t.Errorf("got %v, want %s", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
if InitTable("empty", "s").GetPrimaryKey() != nil {
|
||||
t.Error("empty table must have no PK")
|
||||
}
|
||||
}
|
||||
|
||||
func TestColumnLess(t *testing.T) {
|
||||
tests := []struct {
|
||||
a, b *Column
|
||||
want bool
|
||||
}{
|
||||
{&Column{Name: "a", Sequence: 1}, &Column{Name: "b", Sequence: 2}, true},
|
||||
{&Column{Name: "a", Sequence: 2}, &Column{Name: "b", Sequence: 1}, false},
|
||||
{&Column{Name: "a"}, &Column{Name: "b"}, true},
|
||||
{&Column{Name: "b"}, &Column{Name: "a"}, false},
|
||||
{&Column{Name: "b", Sequence: 1}, &Column{Name: "a"}, false}, // one side unsequenced: by name
|
||||
{&Column{Name: "a", Sequence: 1}, &Column{Name: "b"}, true},
|
||||
}
|
||||
for i, tt := range tests {
|
||||
if got := columnLess(tt.a, tt.b); got != tt.want {
|
||||
t.Errorf("case %d: got %v, want %v", i, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetForeignKeys(t *testing.T) {
|
||||
tbl := InitTable("t", "s")
|
||||
add := func(name string, typ ConstraintType, seq uint) {
|
||||
c := InitConstraint(name, typ)
|
||||
c.Sequence = seq
|
||||
tbl.Constraints[name] = c
|
||||
}
|
||||
add("pk", PrimaryKeyConstraint, 0)
|
||||
add("fk_b", ForeignKeyConstraint, 0)
|
||||
add("fk_a", ForeignKeyConstraint, 0)
|
||||
add("uq", UniqueConstraint, 0)
|
||||
|
||||
got := tbl.GetForeignKeys()
|
||||
if len(got) != 2 || got[0].Name != "fk_a" || got[1].Name != "fk_b" {
|
||||
t.Errorf("by name: %v", got)
|
||||
}
|
||||
|
||||
tbl.Constraints["fk_a"].Sequence = 5
|
||||
tbl.Constraints["fk_b"].Sequence = 2
|
||||
got = tbl.GetForeignKeys()
|
||||
if got[0].Name != "fk_b" || got[1].Name != "fk_a" {
|
||||
t.Errorf("by sequence: %v", got)
|
||||
}
|
||||
|
||||
if got := InitTable("e", "s").GetForeignKeys(); got == nil || len(got) != 0 {
|
||||
t.Errorf("empty table must give non-nil empty slice, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitConstructors(t *testing.T) {
|
||||
db := InitDatabase("db")
|
||||
if db.Name != "db" || db.Schemas == nil || db.Domains == nil || db.Metadata == nil || db.GUID == "" {
|
||||
t.Errorf("InitDatabase: %+v", db)
|
||||
}
|
||||
s := InitSchema("s")
|
||||
if s.Name != "s" || s.Tables == nil || s.Views == nil || s.Sequences == nil || s.Permissions == nil || s.Metadata == nil || s.Scripts == nil || s.GUID == "" {
|
||||
t.Errorf("InitSchema: %+v", s)
|
||||
}
|
||||
tb := InitTable("t", "s")
|
||||
if tb.Name != "t" || tb.Schema != "s" || tb.Columns == nil || tb.Constraints == nil || tb.Indexes == nil || tb.Relationships == nil || tb.Metadata == nil || tb.GUID == "" {
|
||||
t.Errorf("InitTable: %+v", tb)
|
||||
}
|
||||
c := InitColumn("c", "t", "s")
|
||||
if c.Name != "c" || c.Table != "t" || c.Schema != "s" || c.Metadata == nil || c.GUID == "" {
|
||||
t.Errorf("InitColumn: %+v", c)
|
||||
}
|
||||
ix := InitIndex("i", "t", "s")
|
||||
if ix.Name != "i" || ix.Table != "t" || ix.Schema != "s" || ix.Columns == nil || ix.Include == nil || ix.Metadata == nil || ix.GUID == "" {
|
||||
t.Errorf("InitIndex: %+v", ix)
|
||||
}
|
||||
r := InitRelation("r", "s")
|
||||
if r.Name != "r" || r.FromSchema != "s" || r.ToSchema != "s" || r.Properties == nil || r.FromColumns == nil || r.ToColumns == nil || r.GUID == "" {
|
||||
t.Errorf("InitRelation: %+v", r)
|
||||
}
|
||||
rel := InitRelationship("rel", RelationType("one_to_many"))
|
||||
if rel.Name != "rel" || rel.Type != "one_to_many" || rel.Properties == nil || rel.GUID == "" {
|
||||
t.Errorf("InitRelationship: %+v", rel)
|
||||
}
|
||||
con := InitConstraint("k", UniqueConstraint)
|
||||
if con.Name != "k" || con.Type != UniqueConstraint || con.Columns == nil || con.ReferencedColumns == nil || con.GUID == "" {
|
||||
t.Errorf("InitConstraint: %+v", con)
|
||||
}
|
||||
sc := InitScript("sc")
|
||||
if sc.Name != "sc" || sc.RunAfter == nil || sc.Metadata == nil || sc.GUID == "" {
|
||||
t.Errorf("InitScript: %+v", sc)
|
||||
}
|
||||
v := InitView("v", "s")
|
||||
if v.Name != "v" || v.Schema != "s" || v.Columns == nil || v.Metadata == nil || v.GUID == "" {
|
||||
t.Errorf("InitView: %+v", v)
|
||||
}
|
||||
sq := InitSequence("sq", "s")
|
||||
if sq.Name != "sq" || sq.Schema != "s" || sq.IncrementBy != 1 || sq.StartValue != 1 || sq.GUID == "" {
|
||||
t.Errorf("InitSequence: %+v", sq)
|
||||
}
|
||||
d := InitDomain("d")
|
||||
if d.Name != "d" || d.Tables == nil || d.Metadata == nil || d.GUID == "" {
|
||||
t.Errorf("InitDomain: %+v", d)
|
||||
}
|
||||
dt := InitDomainTable("t", "s")
|
||||
if dt.TableName != "t" || dt.SchemaName != "s" || dt.GUID == "" {
|
||||
t.Errorf("InitDomainTable: %+v", dt)
|
||||
}
|
||||
e := InitEnum("e", "s")
|
||||
if e.Name != "e" || e.Schema != "s" || e.Values == nil || e.GUID == "" {
|
||||
t.Errorf("InitEnum: %+v", e)
|
||||
}
|
||||
|
||||
// GUIDs are unique per call.
|
||||
t1, t2 := InitTable("t", "s"), InitTable("t", "s")
|
||||
if t1.GUID == t2.GUID {
|
||||
t.Error("GUIDs must be unique")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type sortCase struct {
|
||||
name string
|
||||
seq uint
|
||||
}
|
||||
|
||||
var sortFixture = []sortCase{{"Banana", 3}, {"apple", 1}, {"Cherry", 2}}
|
||||
|
||||
var (
|
||||
wantNameAsc = []string{"apple", "Banana", "Cherry"}
|
||||
wantNameDesc = []string{"Cherry", "Banana", "apple"}
|
||||
wantSeqAsc = []string{"apple", "Cherry", "Banana"}
|
||||
wantSeqDesc = []string{"Banana", "Cherry", "apple"}
|
||||
)
|
||||
|
||||
func checkNames(t *testing.T, label string, got, want []string) {
|
||||
t.Helper()
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("%s: got %v, want %v", label, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// runSortSuite exercises a by-name and by-sequence sorter pair over the shared fixture.
|
||||
func runSortSuite[T any](t *testing.T, build func(sortCase) T, name func(T) string,
|
||||
byName, bySeq func([]T, bool) error,
|
||||
) {
|
||||
t.Helper()
|
||||
mk := func() []T {
|
||||
out := make([]T, 0, len(sortFixture))
|
||||
for _, c := range sortFixture {
|
||||
out = append(out, build(c))
|
||||
}
|
||||
return out
|
||||
}
|
||||
names := func(items []T) []string {
|
||||
out := make([]string, 0, len(items))
|
||||
for _, it := range items {
|
||||
out = append(out, name(it))
|
||||
}
|
||||
return out
|
||||
}
|
||||
if byName != nil {
|
||||
items := mk()
|
||||
_ = byName(items, false)
|
||||
checkNames(t, "name asc", names(items), wantNameAsc)
|
||||
_ = byName(items, true)
|
||||
checkNames(t, "name desc", names(items), wantNameDesc)
|
||||
_ = byName(nil, false)
|
||||
_ = byName([]T{}, true)
|
||||
}
|
||||
if bySeq != nil {
|
||||
items := mk()
|
||||
_ = bySeq(items, false)
|
||||
checkNames(t, "seq asc", names(items), wantSeqAsc)
|
||||
_ = bySeq(items, true)
|
||||
checkNames(t, "seq desc", names(items), wantSeqDesc)
|
||||
_ = bySeq(nil, false)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSortSchemas(t *testing.T) {
|
||||
runSortSuite(t, func(c sortCase) *Schema { return &Schema{Name: c.name, Sequence: c.seq} },
|
||||
func(s *Schema) string { return s.Name }, SortSchemasByName, SortSchemasBySequence)
|
||||
}
|
||||
|
||||
func TestSortTables(t *testing.T) {
|
||||
runSortSuite(t, func(c sortCase) *Table { return &Table{Name: c.name, Sequence: c.seq} },
|
||||
func(s *Table) string { return s.Name }, SortTablesByName, SortTablesBySequence)
|
||||
}
|
||||
|
||||
func TestSortColumns(t *testing.T) {
|
||||
runSortSuite(t, func(c sortCase) *Column { return &Column{Name: c.name, Sequence: c.seq} },
|
||||
func(s *Column) string { return s.Name }, SortColumnsByName, SortColumnsBySequence)
|
||||
}
|
||||
|
||||
func TestSortViews(t *testing.T) {
|
||||
runSortSuite(t, func(c sortCase) *View { return &View{Name: c.name, Sequence: c.seq} },
|
||||
func(s *View) string { return s.Name }, SortViewsByName, SortViewsBySequence)
|
||||
}
|
||||
|
||||
func TestSortSequences(t *testing.T) {
|
||||
runSortSuite(t, func(c sortCase) *Sequence { return &Sequence{Name: c.name, Sequence: c.seq} },
|
||||
func(s *Sequence) string { return s.Name }, SortSequencesByName, SortSequencesBySequence)
|
||||
}
|
||||
|
||||
func TestSortIndexes(t *testing.T) {
|
||||
runSortSuite(t, func(c sortCase) *Index { return &Index{Name: c.name, Sequence: c.seq} },
|
||||
func(s *Index) string { return s.Name }, SortIndexesByName, SortIndexesBySequence)
|
||||
}
|
||||
|
||||
func TestSortNameOnly(t *testing.T) {
|
||||
runSortSuite(t, func(c sortCase) *Constraint { return &Constraint{Name: c.name} },
|
||||
func(s *Constraint) string { return s.Name }, SortConstraintsByName, nil)
|
||||
runSortSuite(t, func(c sortCase) *Relationship { return &Relationship{Name: c.name} },
|
||||
func(s *Relationship) string { return s.Name }, SortRelationshipsByName, nil)
|
||||
runSortSuite(t, func(c sortCase) *Script { return &Script{Name: c.name} },
|
||||
func(s *Script) string { return s.Name }, SortScriptsByName, nil)
|
||||
runSortSuite(t, func(c sortCase) *Enum { return &Enum{Name: c.name} },
|
||||
func(s *Enum) string { return s.Name }, SortEnumsByName, nil)
|
||||
}
|
||||
|
||||
func TestSortStableForTies(t *testing.T) {
|
||||
cols := []*Column{{Name: "x", Description: "first"}, {Name: "X", Description: "second"}, {Name: "x", Description: "third"}}
|
||||
_ = SortColumnsByName(cols, false)
|
||||
if cols[0].Description != "first" || cols[1].Description != "second" || cols[2].Description != "third" {
|
||||
t.Errorf("ties must keep input order: %v %v %v", cols[0].Description, cols[1].Description, cols[2].Description)
|
||||
}
|
||||
_ = SortColumnsBySequence(cols, true)
|
||||
if cols[0].Description != "first" || cols[2].Description != "third" {
|
||||
t.Errorf("sequence ties must keep input order")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSortMapVariants(t *testing.T) {
|
||||
cols := map[string]*Column{}
|
||||
idx := map[string]*Index{}
|
||||
cons := map[string]*Constraint{}
|
||||
rels := map[string]*Relationship{}
|
||||
for _, c := range sortFixture {
|
||||
cols[c.name] = &Column{Name: c.name, Sequence: c.seq}
|
||||
idx[c.name] = &Index{Name: c.name, Sequence: c.seq}
|
||||
cons[c.name] = &Constraint{Name: c.name}
|
||||
rels[c.name] = &Relationship{Name: c.name}
|
||||
}
|
||||
colNames := func(l []*Column) (o []string) {
|
||||
for _, x := range l {
|
||||
o = append(o, x.Name)
|
||||
}
|
||||
return
|
||||
}
|
||||
idxNames := func(l []*Index) (o []string) {
|
||||
for _, x := range l {
|
||||
o = append(o, x.Name)
|
||||
}
|
||||
return
|
||||
}
|
||||
conNames := func(l []*Constraint) (o []string) {
|
||||
for _, x := range l {
|
||||
o = append(o, x.Name)
|
||||
}
|
||||
return
|
||||
}
|
||||
relNames := func(l []*Relationship) (o []string) {
|
||||
for _, x := range l {
|
||||
o = append(o, x.Name)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
checkNames(t, "cols name", colNames(SortColumnsMapByName(cols, false)), wantNameAsc)
|
||||
checkNames(t, "cols name desc", colNames(SortColumnsMapByName(cols, true)), wantNameDesc)
|
||||
checkNames(t, "cols seq", colNames(SortColumnsMapBySequence(cols, false)), wantSeqAsc)
|
||||
checkNames(t, "cols seq desc", colNames(SortColumnsMapBySequence(cols, true)), wantSeqDesc)
|
||||
checkNames(t, "idx name", idxNames(SortIndexesMapByName(idx, false)), wantNameAsc)
|
||||
checkNames(t, "idx seq", idxNames(SortIndexesMapBySequence(idx, true)), wantSeqDesc)
|
||||
checkNames(t, "con name", conNames(SortConstraintsMapByName(cons, false)), wantNameAsc)
|
||||
checkNames(t, "rel name", relNames(SortRelationshipsMapByName(rels, true)), wantNameDesc)
|
||||
|
||||
if got := SortColumnsMapByName(nil, false); got == nil || len(got) != 0 {
|
||||
t.Errorf("nil map must give non-nil empty slice")
|
||||
}
|
||||
if len(cols) != 3 {
|
||||
t.Error("input map must not be modified")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// viewFixture builds a two-schema database whose map contents would randomise output order.
|
||||
func viewFixture() *Database {
|
||||
db := InitDatabase("shop")
|
||||
db.Description = "desc"
|
||||
db.DatabaseType = PostgresqlDatabaseType
|
||||
db.DatabaseVersion = "16"
|
||||
|
||||
for _, sn := range []string{"sales", "public"} {
|
||||
s := InitSchema(sn)
|
||||
s.Owner = "owner_" + sn
|
||||
s.Scripts = append(s.Scripts, InitScript("seed"))
|
||||
|
||||
users := InitTable("users", sn)
|
||||
for _, cn := range []string{"id", "email", "name"} {
|
||||
c := InitColumn(cn, "users", sn)
|
||||
c.Type = "text"
|
||||
users.Columns[cn] = c
|
||||
}
|
||||
users.Columns["id"].IsPrimaryKey = true
|
||||
users.Columns["id"].NotNull = true
|
||||
|
||||
pk := InitConstraint("users_pkey", PrimaryKeyConstraint)
|
||||
pk.Columns = []string{"id"}
|
||||
users.Constraints["users_pkey"] = pk
|
||||
ck := InitConstraint("users_ck", CheckConstraint)
|
||||
ck.Expression = "id > 0"
|
||||
users.Constraints["users_ck"] = ck
|
||||
users.Indexes["users_idx"] = InitIndex("users_idx", "users", sn)
|
||||
|
||||
orders := InitTable("orders", sn)
|
||||
oid := InitColumn("id", "orders", sn)
|
||||
orders.Columns["id"] = oid
|
||||
uid := InitColumn("user_id", "orders", sn)
|
||||
orders.Columns["user_id"] = uid
|
||||
fk := InitConstraint("orders_user_fk", ForeignKeyConstraint)
|
||||
fk.Columns = []string{"user_id"}
|
||||
fk.ReferencedSchema = sn
|
||||
fk.ReferencedTable = "users"
|
||||
fk.ReferencedColumns = []string{"id"}
|
||||
fk.OnDelete = "CASCADE"
|
||||
orders.Constraints["orders_user_fk"] = fk
|
||||
rel := InitRelationship("orders_users", RelationType("one_to_many"))
|
||||
rel.FromTable, rel.FromSchema = "orders", sn
|
||||
rel.ToTable, rel.ToSchema = "users", sn
|
||||
rel.ForeignKey = "orders_user_fk"
|
||||
rel.ThroughTable, rel.ThroughSchema = "link", sn
|
||||
orders.Relationships["orders_users"] = rel
|
||||
plain := InitRelationship("plain", RelationType("one_to_one"))
|
||||
plain.FromTable, plain.FromSchema = "orders", sn
|
||||
plain.ToTable, plain.ToSchema = "users", sn
|
||||
orders.Relationships["plain"] = plain
|
||||
|
||||
s.Tables = append(s.Tables, users, orders)
|
||||
db.Schemas = append(db.Schemas, s)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func TestToFlatColumns(t *testing.T) {
|
||||
db := viewFixture()
|
||||
first := db.ToFlatColumns()
|
||||
if len(first) != 2*(3+2) {
|
||||
t.Fatalf("got %d columns", len(first))
|
||||
}
|
||||
for i := 1; i < len(first); i++ {
|
||||
if first[i-1].FullyQualifiedName >= first[i].FullyQualifiedName {
|
||||
t.Fatalf("not sorted at %d: %s >= %s", i, first[i-1].FullyQualifiedName, first[i].FullyQualifiedName)
|
||||
}
|
||||
}
|
||||
if first[0].FullyQualifiedName != "shop.public.orders.id" {
|
||||
t.Errorf("first: %s", first[0].FullyQualifiedName)
|
||||
}
|
||||
var id *FlatColumn
|
||||
for _, c := range first {
|
||||
if c.FullyQualifiedName == "shop.sales.users.id" {
|
||||
id = c
|
||||
}
|
||||
}
|
||||
if id == nil || !id.IsPrimaryKey || !id.NotNull || id.Type != "text" || id.DatabaseName != "shop" || id.SchemaName != "sales" || id.TableName != "users" || id.ColumnName != "id" {
|
||||
t.Errorf("flat id column: %+v", id)
|
||||
}
|
||||
for i := 0; i < 20; i++ {
|
||||
if !reflect.DeepEqual(first, db.ToFlatColumns()) {
|
||||
t.Fatal("ToFlatColumns not deterministic")
|
||||
}
|
||||
}
|
||||
if got := InitDatabase("e").ToFlatColumns(); got == nil || len(got) != 0 {
|
||||
t.Errorf("empty db: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToFlatTables(t *testing.T) {
|
||||
got := viewFixture().ToFlatTables()
|
||||
if len(got) != 4 {
|
||||
t.Fatalf("got %d tables", len(got))
|
||||
}
|
||||
// schema order follows the database slice: sales first
|
||||
if got[0].FullyQualifiedName != "shop.sales.users" || got[0].ColumnCount != 3 || got[0].ConstraintCount != 2 || got[0].IndexCount != 1 {
|
||||
t.Errorf("first: %+v", got[0])
|
||||
}
|
||||
if got[1].FullyQualifiedName != "shop.sales.orders" || got[1].ColumnCount != 2 || got[1].ConstraintCount != 1 {
|
||||
t.Errorf("second: %+v", got[1])
|
||||
}
|
||||
if got := InitDatabase("e").ToFlatTables(); got == nil || len(got) != 0 {
|
||||
t.Errorf("empty db: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToFlatConstraints(t *testing.T) {
|
||||
db := viewFixture()
|
||||
got := db.ToFlatConstraints()
|
||||
if len(got) != 6 {
|
||||
t.Fatalf("got %d constraints", len(got))
|
||||
}
|
||||
for i := 1; i < len(got); i++ {
|
||||
if got[i-1].FullyQualifiedName >= got[i].FullyQualifiedName {
|
||||
t.Fatalf("not sorted: %s >= %s", got[i-1].FullyQualifiedName, got[i].FullyQualifiedName)
|
||||
}
|
||||
}
|
||||
var fk, ck *FlatConstraint
|
||||
for _, c := range got {
|
||||
switch c.FullyQualifiedName {
|
||||
case "shop.sales.orders.orders_user_fk":
|
||||
fk = c
|
||||
case "shop.sales.users.users_ck":
|
||||
ck = c
|
||||
}
|
||||
}
|
||||
if fk == nil || fk.ReferencedFQN != "shop.sales.users" || fk.OnDelete != "CASCADE" || fk.Type != ForeignKeyConstraint {
|
||||
t.Errorf("fk: %+v", fk)
|
||||
}
|
||||
if ck == nil || ck.ReferencedFQN != "" || ck.Expression != "id > 0" {
|
||||
t.Errorf("check: %+v", ck)
|
||||
}
|
||||
|
||||
// FK without a referenced table gets no FQN.
|
||||
db2 := InitDatabase("d")
|
||||
s := InitSchema("s")
|
||||
tb := InitTable("t", "s")
|
||||
tb.Constraints["fk"] = InitConstraint("fk", ForeignKeyConstraint)
|
||||
s.Tables = append(s.Tables, tb)
|
||||
db2.Schemas = append(db2.Schemas, s)
|
||||
if out := db2.ToFlatConstraints(); len(out) != 1 || out[0].ReferencedFQN != "" {
|
||||
t.Errorf("unreferenced fk: %+v", out)
|
||||
}
|
||||
if got := InitDatabase("e").ToFlatConstraints(); got == nil || len(got) != 0 {
|
||||
t.Errorf("empty db: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToFlatRelationships(t *testing.T) {
|
||||
db := viewFixture()
|
||||
got := db.ToFlatRelationships()
|
||||
if len(got) != 4 {
|
||||
t.Fatalf("got %d relationships", len(got))
|
||||
}
|
||||
for i := 1; i < len(got); i++ {
|
||||
a, b := got[i-1], got[i]
|
||||
if a.FromFQN > b.FromFQN || (a.FromFQN == b.FromFQN && a.RelationshipName > b.RelationshipName) {
|
||||
t.Fatalf("not sorted at %d", i)
|
||||
}
|
||||
}
|
||||
var through, plain *FlatRelationship
|
||||
for _, r := range got {
|
||||
if r.FromSchema == "sales" && r.RelationshipName == "orders_users" {
|
||||
through = r
|
||||
}
|
||||
if r.FromSchema == "sales" && r.RelationshipName == "plain" {
|
||||
plain = r
|
||||
}
|
||||
}
|
||||
if through == nil || through.ThroughTableFQN != "shop.sales.link" || through.FromFQN != "shop.sales.orders" || through.ToFQN != "shop.sales.users" || through.ForeignKey != "orders_user_fk" {
|
||||
t.Errorf("through: %+v", through)
|
||||
}
|
||||
if plain == nil || plain.ThroughTableFQN != "" {
|
||||
t.Errorf("plain: %+v", plain)
|
||||
}
|
||||
for i := 0; i < 20; i++ {
|
||||
if !reflect.DeepEqual(got, db.ToFlatRelationships()) {
|
||||
t.Fatal("ToFlatRelationships not deterministic")
|
||||
}
|
||||
}
|
||||
if got := InitDatabase("e").ToFlatRelationships(); got == nil || len(got) != 0 {
|
||||
t.Errorf("empty db: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSummaries(t *testing.T) {
|
||||
db := viewFixture()
|
||||
ds := db.ToSummary()
|
||||
if ds.Name != "shop" || ds.Description != "desc" || ds.DatabaseType != PostgresqlDatabaseType || ds.DatabaseVersion != "16" ||
|
||||
ds.SchemaCount != 2 || ds.TotalTables != 4 || ds.TotalColumns != 10 {
|
||||
t.Errorf("database summary: %+v", ds)
|
||||
}
|
||||
if es := InitDatabase("e").ToSummary(); es.SchemaCount != 0 || es.TotalTables != 0 || es.TotalColumns != 0 {
|
||||
t.Errorf("empty summary: %+v", es)
|
||||
}
|
||||
|
||||
ss := db.Schemas[0].ToSummary()
|
||||
if ss.Name != "sales" || ss.Owner != "owner_sales" || ss.TableCount != 2 || ss.ScriptCount != 1 || ss.TotalColumns != 5 || ss.TotalConstraints != 3 {
|
||||
t.Errorf("schema summary: %+v", ss)
|
||||
}
|
||||
|
||||
users := db.Schemas[0].Tables[0].ToSummary()
|
||||
if users.Name != "users" || users.Schema != "sales" || users.ColumnCount != 3 || users.ConstraintCount != 2 || users.IndexCount != 1 ||
|
||||
users.RelationshipCount != 0 || !users.HasPrimaryKey || users.ForeignKeyCount != 0 {
|
||||
t.Errorf("users summary: %+v", users)
|
||||
}
|
||||
orders := db.Schemas[0].Tables[1].ToSummary()
|
||||
if orders.HasPrimaryKey || orders.ForeignKeyCount != 1 || orders.RelationshipCount != 2 {
|
||||
t.Errorf("orders summary: %+v", orders)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDirectiveFromAny(t *testing.T) {
|
||||
want := Directive{Namespace: "postgres", Key: "partition", Args: "partition by RANGE (x)", Line: 7}
|
||||
tests := []struct {
|
||||
name string
|
||||
in any
|
||||
want Directive
|
||||
ok bool
|
||||
}{
|
||||
{"directive", want, want, true},
|
||||
{"string map int line", map[string]any{"namespace": "postgres", "key": "partition", "args": "partition by RANGE (x)", "line": 7}, want, true},
|
||||
{"string map int64 line", map[string]any{"namespace": "postgres", "key": "partition", "args": "partition by RANGE (x)", "line": int64(7)}, want, true},
|
||||
{"string map float64 line", map[string]any{"namespace": "postgres", "key": "partition", "args": "partition by RANGE (x)", "line": float64(7)}, want, true},
|
||||
{"any map ignores non-string keys", map[any]any{"namespace": "postgres", "key": "partition", "args": "partition by RANGE (x)", "line": 7, 5: "x"}, want, true},
|
||||
{"key derived from args", map[string]any{"namespace": "sqlite", "args": "WITHOUT ROWID extra"}, Directive{Namespace: "sqlite", Key: "without", Args: "WITHOUT ROWID extra"}, true},
|
||||
{"wrong field types ignored", map[string]any{"namespace": 1, "key": 2, "args": 3, "line": "x"}, Directive{}, true},
|
||||
{"unsupported type", "nope", Directive{}, false},
|
||||
{"nil", nil, Directive{}, false},
|
||||
{"int", 5, Directive{}, false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, ok := directiveFromAny(tt.in)
|
||||
if ok != tt.ok || got != tt.want {
|
||||
t.Errorf("got (%+v,%v), want (%+v,%v)", got, ok, tt.want, tt.ok)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -64,6 +64,8 @@ The reader recognizes the following Bun struct tags:
|
||||
- `autoincrement` - Auto-increment column
|
||||
- `default` - Default value
|
||||
- `unique` - Unique constraint
|
||||
- `generated` - Generated column marker (`Generated`; expression is not in the tag)
|
||||
- `identity` - Identity column marker (`Identity`, `ALWAYS`)
|
||||
- `rel` - Relationship definition
|
||||
|
||||
## Example Bun Model
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package bun
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers"
|
||||
)
|
||||
|
||||
func TestReader_GeneratedAndIdentityMarkers(t *testing.T) {
|
||||
src := `package models
|
||||
|
||||
import "github.com/uptrace/bun"
|
||||
|
||||
type Person struct {
|
||||
bun.BaseModel ` + "`bun:\"table:people,alias:p\"`" + `
|
||||
|
||||
ID int64 ` + "`bun:\"id,pk,type:bigint,autoincrement,identity\"`" + `
|
||||
Seq int64 ` + "`bun:\"seq,type:bigint,scanonly,identity,notnull\"`" + `
|
||||
FullName string ` + "`bun:\"full_name,type:text,scanonly,generated,nullzero\"`" + `
|
||||
First string ` + "`bun:\"first,type:text,nullzero\"`" + `
|
||||
}
|
||||
`
|
||||
path := filepath.Join(t.TempDir(), "person.go")
|
||||
if err := os.WriteFile(path, []byte(src), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
db, err := NewReader(&readers.ReaderOptions{FilePath: path}).ReadDatabase()
|
||||
if err != nil {
|
||||
t.Fatalf("ReadDatabase() error = %v", err)
|
||||
}
|
||||
cols := db.Schemas[0].Tables[0].Columns
|
||||
|
||||
if !cols["id"].Identity || cols["id"].IdentityGeneration != "ALWAYS" {
|
||||
t.Errorf("id: identity = %v/%q, want true/ALWAYS", cols["id"].Identity, cols["id"].IdentityGeneration)
|
||||
}
|
||||
if !cols["seq"].Identity || cols["seq"].IdentityGeneration != "ALWAYS" {
|
||||
t.Errorf("seq: identity = %v/%q, want true/ALWAYS", cols["seq"].Identity, cols["seq"].IdentityGeneration)
|
||||
}
|
||||
if !cols["full_name"].Generated {
|
||||
t.Error("full_name should be generated")
|
||||
}
|
||||
if cols["first"].Generated || cols["first"].Identity {
|
||||
t.Error("first must be an ordinary column")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package bun
|
||||
|
||||
import (
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"testing"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers"
|
||||
)
|
||||
|
||||
func newTestReader() *Reader { return NewReader(&readers.ReaderOptions{}) }
|
||||
|
||||
func mustExpr(t *testing.T, src string) ast.Expr {
|
||||
t.Helper()
|
||||
e, err := parser.ParseExpr(src)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
func TestGoTypeToSQL(t *testing.T) {
|
||||
r := newTestReader()
|
||||
tests := []struct{ src, want string }{
|
||||
{"int", "integer"},
|
||||
{"int32", "integer"},
|
||||
{"int64", "bigint"},
|
||||
{"string", "text"},
|
||||
{"bool", "boolean"},
|
||||
{"float32", "real"},
|
||||
{"float64", "double precision"},
|
||||
{"uint8", "text"},
|
||||
{"time.Time", "timestamp"},
|
||||
{"time.Duration", "text"},
|
||||
{"sql_types.SqlString", "text"},
|
||||
{"sql_types.SqlInt", "integer"},
|
||||
{"sql_types.SqlInt64", "bigint"},
|
||||
{"sql_types.SqlFloat", "double precision"},
|
||||
{"sql_types.SqlBool", "boolean"},
|
||||
{"sql_types.SqlTime", "timestamp"},
|
||||
{"sql_types.Other", "text"},
|
||||
{"other.Thing", "text"},
|
||||
{"*int64", "bigint"},
|
||||
{"*time.Time", "timestamp"},
|
||||
{"[]byte", "text"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.src, func(t *testing.T) {
|
||||
if got := r.goTypeToSQL(mustExpr(t, tt.src)); got != tt.want {
|
||||
t.Errorf("got %q want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeriveTableName(t *testing.T) {
|
||||
r := newTestReader()
|
||||
for in, want := range map[string]string{
|
||||
"ModelUser": "user",
|
||||
"ModelUserRole": "user_role",
|
||||
"Account": "account",
|
||||
"OrderItem": "order_item",
|
||||
} {
|
||||
if got := r.deriveTableName(in); got != want {
|
||||
t.Errorf("%q: got %q want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetReceiverType(t *testing.T) {
|
||||
r := newTestReader()
|
||||
for src, want := range map[string]string{"User": "User", "*User": "User", "*pkg.User": "", "[]User": ""} {
|
||||
if got := r.getReceiverType(mustExpr(t, src)); got != want {
|
||||
t.Errorf("%s: got %q want %q", src, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRelationType(t *testing.T) {
|
||||
r := newTestReader()
|
||||
for tag, want := range map[string]string{
|
||||
`bun:"rel:has-many,join:id=user_id"`: "has-many",
|
||||
`bun:"rel:belongs-to,join:user_id=id"`: "belongs-to",
|
||||
`bun:"rel:has-one,join:id=user_id"`: "has-one",
|
||||
`bun:"rel:many-to-many,join_table:x"`: "many-to-many",
|
||||
`bun:"rel:unknown"`: "",
|
||||
`bun:"id,pk"`: "",
|
||||
} {
|
||||
if got := r.getRelationType(tag); got != want {
|
||||
t.Errorf("%s: got %q want %q", tag, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTableNameMethod(t *testing.T) {
|
||||
r := newTestReader()
|
||||
parse := func(src string) *ast.FuncDecl {
|
||||
f, err := parser.ParseFile(token.NewFileSet(), "x.go", "package p\n"+src, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return f.Decls[0].(*ast.FuncDecl)
|
||||
}
|
||||
if tbl, sch := r.parseTableNameMethod(parse(`func (User) TableName() string { return "public.users" }`)); tbl != "users" || sch != "public" {
|
||||
t.Errorf("qualified: %q %q", tbl, sch)
|
||||
}
|
||||
if tbl, sch := r.parseTableNameMethod(parse(`func (User) TableName() string { return "users" }`)); tbl != "users" || sch != "public" {
|
||||
t.Errorf("plain: %q %q", tbl, sch)
|
||||
}
|
||||
if tbl, _ := r.parseTableNameMethod(parse(`func (User) TableName() string`)); tbl != "" {
|
||||
t.Errorf("no body: %q", tbl)
|
||||
}
|
||||
if tbl, _ := r.parseTableNameMethod(parse(`func (User) TableName() string { x := 1; _ = x; return foo() }`)); tbl != "" {
|
||||
t.Errorf("non-literal: %q", tbl)
|
||||
}
|
||||
}
|
||||
@@ -659,6 +659,13 @@ func (r *Reader) parseColumn(fieldName string, fieldType ast.Expr, tag string, s
|
||||
hasExplicitNullableMarker = true
|
||||
case "autoincrement":
|
||||
column.AutoIncrement = true
|
||||
case "generated":
|
||||
// GENERATED ... STORED marker written by the Bun writer; the expression is not in the tag
|
||||
column.Generated = true
|
||||
case "identity":
|
||||
// GENERATED ALWAYS AS IDENTITY marker written by the Bun writer
|
||||
column.Identity = true
|
||||
column.IdentityGeneration = "ALWAYS"
|
||||
case "default":
|
||||
// Default value from Bun tag (e.g., default:gen_random_uuid())
|
||||
column.Default = value
|
||||
|
||||
@@ -86,15 +86,56 @@ Ref: posts.user_id > users.id [delete: cascade]
|
||||
- Table definitions with columns
|
||||
- Primary keys (`pk`)
|
||||
- Not null constraints (`not null`)
|
||||
- Unique constraints (`unique`)
|
||||
- Unique constraints (`unique`) → constraint `ukey_<table>_<col>`
|
||||
- Default values (`default`)
|
||||
- Inline references (`ref`)
|
||||
- Standalone `Ref` blocks
|
||||
- FK deferral: `deferrable`, `initially deferred` (see below)
|
||||
- Commented cross-file refs (`// Ref:` — see below)
|
||||
- Indexes and composite indexes
|
||||
- Table notes and column notes
|
||||
- Enums
|
||||
- Dialect directives (`@postgres:` / `@sqlite:` — see below)
|
||||
|
||||
## Naming
|
||||
|
||||
| Source | Generated name |
|
||||
| ------ | -------------- |
|
||||
| Column `[unique]` | constraint `ukey_<table>_<col>` |
|
||||
| Unnamed unique index | index `ukey_<table>_<cols>` (same name as the constraint on the same columns) |
|
||||
| Unnamed index | `idx_<table>_<cols>` |
|
||||
|
||||
Explicit `name:` is never changed. Leading `_` in the table name is stripped.
|
||||
|
||||
## FK deferral
|
||||
|
||||
```
|
||||
col int [ref: > t.id, deferrable, initially deferred]
|
||||
Ref: a.x > b.id [delete: cascade, deferrable]
|
||||
```
|
||||
|
||||
| Setting | SQL |
|
||||
| ------- | --- |
|
||||
| `deferrable` | `DEFERRABLE` |
|
||||
| `initially deferred` | `DEFERRABLE INITIALLY DEFERRED` |
|
||||
| neither | unspecified (existing FK left as is) |
|
||||
|
||||
## Commented cross-file refs
|
||||
|
||||
`// Ref:` / `// ref:` lines (ignored by dbdiagram) become FKs + relationships once both ends are loaded.
|
||||
|
||||
| Rule | Behaviour |
|
||||
|---|---|
|
||||
| When | Single file / directory: end of read. `--from-list`, `merge`, jobs: after all inputs are combined |
|
||||
| Match | `schema.table.column` on both sides, case-insensitive |
|
||||
| Operators | `>`, `<`, `-` (parsed like `Ref:`) |
|
||||
| Duplicate of an FK on the same columns | Skipped silently |
|
||||
| Target not loaded | Kept pending; skipped with a warning on the final pass |
|
||||
| Column type mismatch | Warning, FK still created (`serial`≈`integer`, `bigserial`≈`bigint`) |
|
||||
| Pending state | `Database.Metadata["dbml.commented_refs"]` (`[]string`) |
|
||||
|
||||
API: `dbml.ResolveCommentedRefs(db, final bool) []string` returns warnings.
|
||||
|
||||
## Dialect directives
|
||||
|
||||
Lines of the form `@<namespace>[(<column>)]: <args>` embed database-specific
|
||||
@@ -140,6 +181,7 @@ grammar and the supported-directive matrix.
|
||||
|
||||
## Notes
|
||||
|
||||
- Column notes `GENERATED ALWAYS AS (expr) STORED` and `GENERATED ALWAYS|BY DEFAULT AS IDENTITY` set `Generated`/`GenerationExpression` and `Identity`/`IdentityGeneration`; they are not kept as comments
|
||||
- DBML is designed for database documentation and diagramming
|
||||
- Schema name defaults to `public`
|
||||
- Relationship cardinality is preserved
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
package dbml
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||
)
|
||||
|
||||
// CommentedRefsMetadataKey is the Database.Metadata key holding commented
|
||||
// `// Ref:` lines not yet resolved against the model ([]string).
|
||||
const CommentedRefsMetadataKey = "dbml.commented_refs"
|
||||
|
||||
// commentedRefRegex matches `// Ref: ...` and `// ref: ...`.
|
||||
var commentedRefRegex = regexp.MustCompile(`^//\s*[Rr]ef\s*:\s*(.+)$`)
|
||||
|
||||
// commentedRef returns the ref body of a trimmed `// Ref:` comment line.
|
||||
func commentedRef(line string) (string, bool) {
|
||||
m := commentedRefRegex.FindStringSubmatch(line)
|
||||
if m == nil {
|
||||
return "", false
|
||||
}
|
||||
ref := strings.TrimSpace(m[1])
|
||||
return ref, ref != ""
|
||||
}
|
||||
|
||||
// PendingCommentedRefs returns the commented refs not yet resolved.
|
||||
func PendingCommentedRefs(db *models.Database) []string {
|
||||
if db == nil || db.Metadata == nil {
|
||||
return nil
|
||||
}
|
||||
refs, _ := db.Metadata[CommentedRefsMetadataKey].([]string)
|
||||
return refs
|
||||
}
|
||||
|
||||
func setPendingCommentedRefs(db *models.Database, refs []string) {
|
||||
if len(refs) == 0 {
|
||||
if db.Metadata != nil {
|
||||
delete(db.Metadata, CommentedRefsMetadataKey)
|
||||
}
|
||||
return
|
||||
}
|
||||
if db.Metadata == nil {
|
||||
db.Metadata = make(map[string]any)
|
||||
}
|
||||
db.Metadata[CommentedRefsMetadataKey] = refs
|
||||
}
|
||||
|
||||
// addPendingCommentedRef queues a ref, skipping exact repeats.
|
||||
func addPendingCommentedRef(db *models.Database, ref string) {
|
||||
refs := PendingCommentedRefs(db)
|
||||
for _, existing := range refs {
|
||||
if existing == ref {
|
||||
return
|
||||
}
|
||||
}
|
||||
setPendingCommentedRefs(db, append(refs, ref))
|
||||
}
|
||||
|
||||
// ResolveCommentedRefs turns pending commented refs into foreign keys and
|
||||
// relationships when both ends (schema.table.column) exist in db. A ref that
|
||||
// matches an existing FK on the same columns is dropped as a duplicate.
|
||||
// Unmatched refs stay pending unless final is set, in which case they are
|
||||
// dropped with a warning. Returns human-readable warnings.
|
||||
func ResolveCommentedRefs(db *models.Database, final bool) []string {
|
||||
refs := PendingCommentedRefs(db)
|
||||
if len(refs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var warnings []string
|
||||
var pending []string
|
||||
parser := &Reader{}
|
||||
|
||||
for _, ref := range refs {
|
||||
fk := parser.parseRef(ref)
|
||||
if fk == nil || len(fk.Columns) == 0 || len(fk.Columns) != len(fk.ReferencedColumns) {
|
||||
warnings = append(warnings, fmt.Sprintf("skipping commented ref %q: cannot parse", ref))
|
||||
continue
|
||||
}
|
||||
|
||||
srcTable, srcCols, srcMissing := lookupColumns(db, fk.Schema, fk.Table, fk.Columns)
|
||||
dstTable, dstCols, dstMissing := lookupColumns(db, fk.ReferencedSchema, fk.ReferencedTable, fk.ReferencedColumns)
|
||||
if srcMissing != "" || dstMissing != "" {
|
||||
if final {
|
||||
missing := srcMissing
|
||||
if missing == "" {
|
||||
missing = dstMissing
|
||||
}
|
||||
warnings = append(warnings, fmt.Sprintf("skipping commented ref %q: %s not found", ref, missing))
|
||||
} else {
|
||||
pending = append(pending, ref)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
fk.Schema, fk.Table, fk.Columns = srcTable.Schema, srcTable.Name, columnNames(srcCols)
|
||||
fk.ReferencedSchema, fk.ReferencedTable, fk.ReferencedColumns = dstTable.Schema, dstTable.Name, columnNames(dstCols)
|
||||
|
||||
if hasFKOnColumns(srcTable, fk.Columns) {
|
||||
continue // already declared by an uncommented Ref or inline ref
|
||||
}
|
||||
if _, taken := srcTable.Constraints[fk.Name]; taken {
|
||||
warnings = append(warnings, fmt.Sprintf("skipping commented ref %q: constraint %s already exists", ref, fk.Name))
|
||||
continue
|
||||
}
|
||||
|
||||
for i := range srcCols {
|
||||
if !compatibleFKTypes(srcCols[i].Type, dstCols[i].Type) {
|
||||
warnings = append(warnings, fmt.Sprintf("commented ref %q: type mismatch %s.%s.%s (%s) -> %s.%s.%s (%s)",
|
||||
ref, srcTable.Schema, srcTable.Name, srcCols[i].Name, srcCols[i].Type,
|
||||
dstTable.Schema, dstTable.Name, dstCols[i].Name, dstCols[i].Type))
|
||||
}
|
||||
}
|
||||
|
||||
if srcTable.Constraints == nil {
|
||||
srcTable.Constraints = make(map[string]*models.Constraint)
|
||||
}
|
||||
srcTable.Constraints[fk.Name] = fk
|
||||
addFKRelationship(srcTable, fk)
|
||||
}
|
||||
|
||||
setPendingCommentedRefs(db, pending)
|
||||
return warnings
|
||||
}
|
||||
|
||||
// lookupColumns finds a table and its columns, case-insensitively. missing
|
||||
// names the first object not found, or is empty.
|
||||
func lookupColumns(db *models.Database, schemaName, tableName string, cols []string) (*models.Table, []*models.Column, string) {
|
||||
qualified := schemaName + "." + tableName
|
||||
var table *models.Table
|
||||
for _, schema := range db.Schemas {
|
||||
if !strings.EqualFold(schema.Name, schemaName) {
|
||||
continue
|
||||
}
|
||||
for _, t := range schema.Tables {
|
||||
if strings.EqualFold(t.Name, tableName) {
|
||||
table = t
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if table == nil {
|
||||
return nil, nil, "table " + qualified
|
||||
}
|
||||
|
||||
found := make([]*models.Column, 0, len(cols))
|
||||
for _, name := range cols {
|
||||
col := table.Columns[name]
|
||||
if col == nil {
|
||||
for _, c := range table.Columns {
|
||||
if strings.EqualFold(c.Name, name) {
|
||||
col = c
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if col == nil {
|
||||
return nil, nil, "column " + qualified + "." + name
|
||||
}
|
||||
found = append(found, col)
|
||||
}
|
||||
return table, found, ""
|
||||
}
|
||||
|
||||
func columnNames(cols []*models.Column) []string {
|
||||
names := make([]string, len(cols))
|
||||
for i, c := range cols {
|
||||
names[i] = c.Name
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// hasFKOnColumns reports whether table already has a foreign key over cols.
|
||||
func hasFKOnColumns(table *models.Table, cols []string) bool {
|
||||
for _, c := range table.Constraints {
|
||||
if c.Type != models.ForeignKeyConstraint || len(c.Columns) != len(cols) {
|
||||
continue
|
||||
}
|
||||
same := true
|
||||
for i := range cols {
|
||||
if !strings.EqualFold(c.Columns[i], cols[i]) {
|
||||
same = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if same {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// fkTypeAliases maps serial and alias spellings to their storage type.
|
||||
var fkTypeAliases = map[string]string{
|
||||
"smallserial": "smallint", "serial2": "smallint", "int2": "smallint",
|
||||
"serial": "integer", "serial4": "integer", "int": "integer", "int4": "integer",
|
||||
"bigserial": "bigint", "serial8": "bigint", "int8": "bigint",
|
||||
}
|
||||
|
||||
// compatibleFKTypes compares column types ignoring case, length and serial
|
||||
// vs. integer spelling. Unknown (empty) types are treated as compatible.
|
||||
func compatibleFKTypes(a, b string) bool {
|
||||
na, nb := normalizeFKType(a), normalizeFKType(b)
|
||||
return na == "" || nb == "" || na == nb
|
||||
}
|
||||
|
||||
func normalizeFKType(t string) string {
|
||||
t = strings.ToLower(strings.TrimSpace(t))
|
||||
if i := strings.Index(t, "("); i >= 0 {
|
||||
t = strings.TrimSpace(t[:i])
|
||||
}
|
||||
if alias, ok := fkTypeAliases[t]; ok {
|
||||
return alias
|
||||
}
|
||||
return t
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
package dbml
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers"
|
||||
)
|
||||
|
||||
func readDBMLString(t *testing.T, content string) *models.Database {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "in.dbml")
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
t.Fatalf("write fixture: %v", err)
|
||||
}
|
||||
db, err := NewReader(&readers.ReaderOptions{FilePath: path}).ReadDatabase()
|
||||
if err != nil {
|
||||
t.Fatalf("ReadDatabase() error = %v", err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func findTable(db *models.Database, schema, table string) *models.Table {
|
||||
for _, s := range db.Schemas {
|
||||
if s.Name != schema {
|
||||
continue
|
||||
}
|
||||
for _, t := range s.Tables {
|
||||
if t.Name == table {
|
||||
return t
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func fkOn(table *models.Table, col string) *models.Constraint {
|
||||
for _, c := range table.Constraints {
|
||||
if c.Type == models.ForeignKeyConstraint && len(c.Columns) == 1 && c.Columns[0] == col {
|
||||
return c
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func relFor(table *models.Table, fkName string) *models.Relationship {
|
||||
for _, r := range table.Relationships {
|
||||
if r.ForeignKey == fkName {
|
||||
return r
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestCommentedRef(t *testing.T) {
|
||||
tests := []struct {
|
||||
line string
|
||||
want string
|
||||
ok bool
|
||||
}{
|
||||
{`// Ref: a.b.c > d.e.f`, `a.b.c > d.e.f`, true},
|
||||
{`// ref: a.b.c - d.e.f`, `a.b.c - d.e.f`, true},
|
||||
{`//Ref:a.b.c > d.e.f`, `a.b.c > d.e.f`, true},
|
||||
{`// Reference notes`, "", false},
|
||||
{`// see Ref: a.b.c > d.e.f`, "", false},
|
||||
{`// Ref:`, "", false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got, ok := commentedRef(tt.line)
|
||||
if ok != tt.ok || got != tt.want {
|
||||
t.Errorf("commentedRef(%q) = (%q, %v), want (%q, %v)", tt.line, got, ok, tt.want, tt.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A commented ref whose tables are in the same file resolves on read.
|
||||
func TestReader_CommentedRefSameFile(t *testing.T) {
|
||||
db := readDBMLString(t, `Table "org"."department" {
|
||||
"id_department" bigserial [pk]
|
||||
}
|
||||
Table "entity"."employee" {
|
||||
"id_employee" bigserial [pk]
|
||||
"rid_department" bigint
|
||||
}
|
||||
// Ref: "entity"."employee"."rid_department" > "org"."department"."id_department" [delete: restrict, update: restrict]
|
||||
`)
|
||||
emp := findTable(db, "entity", "employee")
|
||||
fk := fkOn(emp, "rid_department")
|
||||
if fk == nil {
|
||||
t.Fatal("expected FK on rid_department")
|
||||
}
|
||||
if fk.ReferencedSchema != "org" || fk.ReferencedTable != "department" || fk.ReferencedColumns[0] != "id_department" {
|
||||
t.Errorf("FK target = %s.%s.%v", fk.ReferencedSchema, fk.ReferencedTable, fk.ReferencedColumns)
|
||||
}
|
||||
if fk.OnDelete != "restrict" || fk.OnUpdate != "restrict" {
|
||||
t.Errorf("FK actions = %q/%q, want restrict/restrict", fk.OnDelete, fk.OnUpdate)
|
||||
}
|
||||
if relFor(emp, fk.Name) == nil {
|
||||
t.Error("expected relationship for FK")
|
||||
}
|
||||
if refs := PendingCommentedRefs(db); len(refs) != 0 {
|
||||
t.Errorf("pending = %v, want none", refs)
|
||||
}
|
||||
}
|
||||
|
||||
// A cross-file commented ref stays pending after a single-file read.
|
||||
func TestReader_CommentedRefCrossFilePending(t *testing.T) {
|
||||
db := readDBMLString(t, `Table "entity"."employee" {
|
||||
"id_employee" bigserial [pk]
|
||||
"rid_department" bigint
|
||||
}
|
||||
// Ref: "entity"."employee"."rid_department" > "org"."department"."id_department"
|
||||
`)
|
||||
if fk := fkOn(findTable(db, "entity", "employee"), "rid_department"); fk != nil {
|
||||
t.Fatal("FK must not resolve without the target table")
|
||||
}
|
||||
if refs := PendingCommentedRefs(db); len(refs) != 1 {
|
||||
t.Fatalf("pending = %v, want 1", refs)
|
||||
}
|
||||
}
|
||||
|
||||
// Directory reads resolve commented refs after all files are merged.
|
||||
func TestReader_CommentedRefDirectory(t *testing.T) {
|
||||
db, err := NewReader(&readers.ReaderOptions{
|
||||
FilePath: filepath.Join("..", "..", "..", "tests", "assets", "dbml", "multifile"),
|
||||
}).ReadDatabase()
|
||||
if err != nil {
|
||||
t.Fatalf("ReadDatabase() error = %v", err)
|
||||
}
|
||||
fk := fkOn(findTable(db, "public", "posts"), "user_id")
|
||||
if fk == nil {
|
||||
t.Fatal("expected FK posts.user_id from 9_refs.dbml commented ref")
|
||||
}
|
||||
if fk.ReferencedTable != "users" || fk.OnDelete != "CASCADE" {
|
||||
t.Errorf("FK = %s ondelete %s, want users ondelete CASCADE", fk.ReferencedTable, fk.OnDelete)
|
||||
}
|
||||
}
|
||||
|
||||
func crossFileDB(t *testing.T, refs ...string) *models.Database {
|
||||
t.Helper()
|
||||
db := readDBMLString(t, `Table "org"."department" {
|
||||
"id_department" bigserial [pk]
|
||||
}
|
||||
Table "entity"."employee" {
|
||||
"id_employee" bigserial [pk]
|
||||
"rid_department" bigint
|
||||
"rid_manager" integer
|
||||
"rid_team" bigint
|
||||
}
|
||||
`)
|
||||
setPendingCommentedRefs(db, refs)
|
||||
return db
|
||||
}
|
||||
|
||||
func TestResolveCommentedRefs(t *testing.T) {
|
||||
t.Run("lowercase ref and one-to-one", func(t *testing.T) {
|
||||
db := crossFileDB(t,
|
||||
`"entity"."employee"."rid_department" > "org"."department"."id_department"`,
|
||||
`entity.employee.rid_team - org.department.id_department`,
|
||||
)
|
||||
if w := ResolveCommentedRefs(db, true); len(w) != 0 {
|
||||
t.Errorf("warnings = %v, want none", w)
|
||||
}
|
||||
emp := findTable(db, "entity", "employee")
|
||||
for _, col := range []string{"rid_department", "rid_team"} {
|
||||
fk := fkOn(emp, col)
|
||||
if fk == nil {
|
||||
t.Fatalf("expected FK on %s", col)
|
||||
}
|
||||
if relFor(emp, fk.Name) == nil {
|
||||
t.Errorf("expected relationship for %s", fk.Name)
|
||||
}
|
||||
}
|
||||
if len(emp.Relationships) != 2 {
|
||||
t.Errorf("relationships = %d, want 2 (same target must not overwrite)", len(emp.Relationships))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("type mismatch warns but resolves", func(t *testing.T) {
|
||||
db := crossFileDB(t, `entity.employee.rid_manager > org.department.id_department`)
|
||||
w := ResolveCommentedRefs(db, true)
|
||||
if len(w) != 1 || !strings.Contains(w[0], "type mismatch") {
|
||||
t.Errorf("warnings = %v, want one type mismatch", w)
|
||||
}
|
||||
if fkOn(findTable(db, "entity", "employee"), "rid_manager") == nil {
|
||||
t.Error("expected FK on rid_manager")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing target stays pending until final", func(t *testing.T) {
|
||||
db := crossFileDB(t, `entity.employee.rid_team > hr.team.id_team`)
|
||||
if w := ResolveCommentedRefs(db, false); len(w) != 0 {
|
||||
t.Errorf("non-final warnings = %v, want none", w)
|
||||
}
|
||||
if len(PendingCommentedRefs(db)) != 1 {
|
||||
t.Fatal("ref should stay pending")
|
||||
}
|
||||
w := ResolveCommentedRefs(db, true)
|
||||
if len(w) != 1 || !strings.Contains(w[0], "table hr.team not found") {
|
||||
t.Errorf("final warnings = %v, want missing table", w)
|
||||
}
|
||||
if len(PendingCommentedRefs(db)) != 0 {
|
||||
t.Error("final pass must clear pending refs")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing column", func(t *testing.T) {
|
||||
db := crossFileDB(t, `entity.employee.rid_nope > org.department.id_department`)
|
||||
w := ResolveCommentedRefs(db, true)
|
||||
if len(w) != 1 || !strings.Contains(w[0], "column entity.employee.rid_nope not found") {
|
||||
t.Errorf("warnings = %v, want missing column", w)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("deduplicates against uncommented ref", func(t *testing.T) {
|
||||
db := readDBMLString(t, `Table "org"."department" {
|
||||
"id_department" bigserial [pk]
|
||||
}
|
||||
Table "entity"."employee" {
|
||||
"id_employee" bigserial [pk]
|
||||
"rid_department" bigint
|
||||
}
|
||||
Ref: "entity"."employee"."rid_department" > "org"."department"."id_department"
|
||||
// Ref: "entity"."employee"."rid_department" > "org"."department"."id_department"
|
||||
`)
|
||||
emp := findTable(db, "entity", "employee")
|
||||
count := 0
|
||||
for _, c := range emp.Constraints {
|
||||
if c.Type == models.ForeignKeyConstraint {
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count != 1 || len(emp.Relationships) != 1 {
|
||||
t.Errorf("FKs = %d, relationships = %d, want 1 and 1", count, len(emp.Relationships))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCompatibleFKTypes(t *testing.T) {
|
||||
tests := []struct {
|
||||
a, b string
|
||||
want bool
|
||||
}{
|
||||
{"bigint", "bigserial", true},
|
||||
{"integer", "serial", true},
|
||||
{"INT4", "integer", true},
|
||||
{"varchar(10)", "varchar(20)", true},
|
||||
{"bigint", "serial", false},
|
||||
{"uuid", "bigint", false},
|
||||
{"", "bigint", true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := compatibleFKTypes(tt.a, tt.b); got != tt.want {
|
||||
t.Errorf("compatibleFKTypes(%q, %q) = %v, want %v", tt.a, tt.b, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package dbml
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||
)
|
||||
|
||||
var (
|
||||
generatedNoteRegex = regexp.MustCompile(`(?is)^GENERATED\s+ALWAYS\s+AS\s*\((.*)\)\s*STORED$`)
|
||||
identityNoteRegex = regexp.MustCompile(`(?i)^GENERATED\s+(ALWAYS|BY\s+DEFAULT)\s+AS\s+IDENTITY$`)
|
||||
)
|
||||
|
||||
// applyColumnMarkerNote interprets a column note written by the DBML writer for
|
||||
// GENERATED ... STORED and GENERATED ... AS IDENTITY columns, setting the matching
|
||||
// model fields. It returns true when the note was a marker, in which case it is not
|
||||
// a user comment.
|
||||
func applyColumnMarkerNote(column *models.Column, rawNote string) bool {
|
||||
note := strings.TrimSpace(rawNote)
|
||||
if len(note) >= 2 && (note[0] == '\'' || note[0] == '"') && note[len(note)-1] == note[0] {
|
||||
note = note[1 : len(note)-1]
|
||||
}
|
||||
note = strings.ReplaceAll(strings.ReplaceAll(note, `\'`, `'`), `\\`, `\`)
|
||||
note = strings.TrimSpace(note)
|
||||
|
||||
if m := generatedNoteRegex.FindStringSubmatch(note); m != nil {
|
||||
column.Generated = true
|
||||
column.GenerationExpression = strings.TrimSpace(m[1])
|
||||
return true
|
||||
}
|
||||
if m := identityNoteRegex.FindStringSubmatch(note); m != nil {
|
||||
column.Identity = true
|
||||
column.IdentityGeneration = strings.Join(strings.Fields(strings.ToUpper(m[1])), " ")
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
+98
-18
@@ -48,7 +48,22 @@ func (r *Reader) ReadDatabase() (*models.Database, error) {
|
||||
return nil, fmt.Errorf("failed to read file: %w", err)
|
||||
}
|
||||
|
||||
return r.parseDBML(string(content))
|
||||
db, err := r.parseDBML(string(content))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r.resolveCommentedRefs(db)
|
||||
return db, nil
|
||||
}
|
||||
|
||||
// resolveCommentedRefs resolves the commented refs whose tables are loaded.
|
||||
// Unmatched refs stay pending for a later pass over a combined model.
|
||||
func (r *Reader) resolveCommentedRefs(db *models.Database) {
|
||||
for _, w := range ResolveCommentedRefs(db, false) {
|
||||
if r.options.Progress != nil {
|
||||
r.options.Progress("warning: " + w)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ReadSchema reads and parses DBML input, returning a Schema model
|
||||
@@ -125,6 +140,7 @@ func (r *Reader) readDirectoryDBML(dirPath string) (*models.Database, error) {
|
||||
}
|
||||
}
|
||||
|
||||
r.resolveCommentedRefs(db)
|
||||
return db, nil
|
||||
}
|
||||
|
||||
@@ -440,6 +456,10 @@ func mergeDatabase(baseDB, fileDB *models.Database) {
|
||||
// Merge domains
|
||||
baseDB.Domains = append(baseDB.Domains, fileDB.Domains...)
|
||||
|
||||
for _, ref := range PendingCommentedRefs(fileDB) {
|
||||
addPendingCommentedRef(baseDB, ref)
|
||||
}
|
||||
|
||||
// Use first non-empty description
|
||||
if baseDB.Description == "" && fileDB.Description != "" {
|
||||
baseDB.Description = fileDB.Description
|
||||
@@ -493,8 +513,12 @@ func (r *Reader) parseDBML(content string) (*models.Database, error) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip empty lines and comments
|
||||
// Skip empty lines and comments. A commented `// Ref:` is kept as a
|
||||
// pending cross-file ref, resolved once every file is loaded.
|
||||
if line == "" || strings.HasPrefix(line, "//") {
|
||||
if ref, ok := commentedRef(line); ok {
|
||||
addPendingCommentedRef(db, ref)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -581,7 +605,13 @@ func (r *Reader) parseDBML(content string) (*models.Database, error) {
|
||||
|
||||
index := r.parseIndex(line, currentTable.Name, currentSchema)
|
||||
if index != nil {
|
||||
currentTable.Indexes[index.Name] = index
|
||||
// Keep a reused name under a unique map key so the duplicate is
|
||||
// not silently dropped; the inspector reports it.
|
||||
key := index.Name
|
||||
for n := 2; currentTable.Indexes[key] != nil; n++ {
|
||||
key = fmt.Sprintf("%s#%d", index.Name, n)
|
||||
}
|
||||
currentTable.Indexes[key] = index
|
||||
lastIndex = index
|
||||
}
|
||||
continue
|
||||
@@ -659,20 +689,12 @@ func (r *Reader) parseDBML(content string) (*models.Database, error) {
|
||||
// for DBML refs so diffing equivalent schemas compares the same model.
|
||||
for _, schema := range schemaMap {
|
||||
for _, table := range schema.Tables {
|
||||
for _, constraint := range table.Constraints {
|
||||
for _, name := range sortedConstraintNames(table.Constraints) {
|
||||
constraint := table.Constraints[name]
|
||||
if constraint.Type != models.ForeignKeyConstraint {
|
||||
continue
|
||||
}
|
||||
name := fmt.Sprintf("%s_to_%s", table.Name, constraint.ReferencedTable)
|
||||
relationship := models.InitRelationship(name, models.OneToMany)
|
||||
relationship.FromTable = table.Name
|
||||
relationship.FromSchema = table.Schema
|
||||
relationship.FromColumns = append([]string(nil), constraint.Columns...)
|
||||
relationship.ToTable = constraint.ReferencedTable
|
||||
relationship.ToSchema = constraint.ReferencedSchema
|
||||
relationship.ToColumns = append([]string(nil), constraint.ReferencedColumns...)
|
||||
relationship.ForeignKey = constraint.Name
|
||||
table.Relationships[name] = relationship
|
||||
addFKRelationship(table, constraint)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -685,6 +707,39 @@ func (r *Reader) parseDBML(content string) (*models.Database, error) {
|
||||
return db, nil
|
||||
}
|
||||
|
||||
// sortedConstraintNames returns constraint keys in sorted order so derived
|
||||
// relationship names do not depend on map iteration order.
|
||||
func sortedConstraintNames(constraints map[string]*models.Constraint) []string {
|
||||
names := make([]string, 0, len(constraints))
|
||||
for name := range constraints {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
return names
|
||||
}
|
||||
|
||||
// addFKRelationship derives the relationship for a foreign key, matching how
|
||||
// the PostgreSQL reader models FKs.
|
||||
func addFKRelationship(table *models.Table, constraint *models.Constraint) {
|
||||
name := fmt.Sprintf("%s_to_%s", table.Name, constraint.ReferencedTable)
|
||||
if existing, taken := table.Relationships[name]; taken && existing.ForeignKey != constraint.Name {
|
||||
// A second FK to the same table must not overwrite the first.
|
||||
name = fmt.Sprintf("%s_%s", name, strings.Join(constraint.Columns, "_"))
|
||||
}
|
||||
relationship := models.InitRelationship(name, models.OneToMany)
|
||||
relationship.FromTable = table.Name
|
||||
relationship.FromSchema = table.Schema
|
||||
relationship.FromColumns = append([]string(nil), constraint.Columns...)
|
||||
relationship.ToTable = constraint.ReferencedTable
|
||||
relationship.ToSchema = constraint.ReferencedSchema
|
||||
relationship.ToColumns = append([]string(nil), constraint.ReferencedColumns...)
|
||||
relationship.ForeignKey = constraint.Name
|
||||
if table.Relationships == nil {
|
||||
table.Relationships = make(map[string]*models.Relationship)
|
||||
}
|
||||
table.Relationships[name] = relationship
|
||||
}
|
||||
|
||||
// setTableNote preserves multiple table notes. The first maps to Description
|
||||
// and the second to Comment, matching the model fields used by code writers.
|
||||
func setTableNote(table *models.Table, note string) {
|
||||
@@ -749,7 +804,9 @@ func (r *Reader) parseColumn(line, tableName, schemaName string) (*models.Column
|
||||
} else if strings.HasPrefix(attr, "note:") {
|
||||
// Parse column note/comment
|
||||
note := strings.TrimSpace(strings.TrimPrefix(attr, "note:"))
|
||||
column.Comment = strings.Trim(note, "'\"")
|
||||
if !applyColumnMarkerNote(column, note) {
|
||||
column.Comment = strings.Trim(note, "'\"")
|
||||
}
|
||||
} else if strings.HasPrefix(attr, "ref:") {
|
||||
// Parse inline reference
|
||||
// DBML semantics depend on context:
|
||||
@@ -791,6 +848,13 @@ func (r *Reader) parseColumn(line, tableName, schemaName string) (*models.Column
|
||||
}
|
||||
}
|
||||
|
||||
// "deferrable" / "initially deferred" column settings apply to the inline foreign key
|
||||
if constraint != nil && constraint.Type == models.ForeignKeyConstraint && attrs != "" {
|
||||
for _, attr := range splitColumnAttrs(attrs) {
|
||||
applyDeferralSetting(constraint, attr)
|
||||
}
|
||||
}
|
||||
|
||||
// Parse inline comment
|
||||
if inlineComment != "" {
|
||||
column.Comment = inlineComment
|
||||
@@ -1024,11 +1088,13 @@ func (r *Reader) parseIndex(line, tableName, schemaName string) *models.Index {
|
||||
|
||||
// Generate name if not provided
|
||||
if index.Name == "" {
|
||||
prefix := "idx"
|
||||
// Unique indexes share the ukey_ naming of unique constraints, so an index and a
|
||||
// constraint on the same columns get the same name and can be merged on write.
|
||||
prefix, name := "idx", tableName
|
||||
if index.Unique {
|
||||
prefix = "uidx"
|
||||
prefix, name = "ukey", strings.TrimLeft(tableName, "_")
|
||||
}
|
||||
index.Name = fmt.Sprintf("%s_%s_%s", prefix, tableName, strings.Join(columns, "_"))
|
||||
index.Name = fmt.Sprintf("%s_%s_%s", prefix, name, strings.Join(columns, "_"))
|
||||
}
|
||||
|
||||
return index
|
||||
@@ -1125,6 +1191,8 @@ func (r *Reader) parseRef(refStr string) *models.Constraint {
|
||||
constraint.OnDelete = strings.TrimSpace(strings.TrimPrefix(action, "ondelete:"))
|
||||
} else if strings.HasPrefix(action, "onupdate:") {
|
||||
constraint.OnUpdate = strings.TrimSpace(strings.TrimPrefix(action, "onupdate:"))
|
||||
} else {
|
||||
applyDeferralSetting(constraint, action)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1133,6 +1201,18 @@ func (r *Reader) parseRef(refStr string) *models.Constraint {
|
||||
return constraint
|
||||
}
|
||||
|
||||
// applyDeferralSetting applies a "deferrable" or "initially deferred" Ref setting to a
|
||||
// foreign key constraint. Other settings are ignored.
|
||||
func applyDeferralSetting(constraint *models.Constraint, setting string) {
|
||||
yes := true
|
||||
switch strings.Join(strings.Fields(strings.ToLower(setting)), " ") {
|
||||
case "deferrable":
|
||||
constraint.Deferrable = &yes
|
||||
case "initially deferred", "initially_deferred":
|
||||
constraint.InitiallyDeferred = &yes
|
||||
}
|
||||
}
|
||||
|
||||
// parseTableRef parses a table reference like "schema.table.(column1, column2)" or "schema"."table"."column"
|
||||
func (r *Reader) parseTableRef(ref string) (schema, table string, columns []string) {
|
||||
// Extract columns if present in parentheses format
|
||||
|
||||
@@ -3,6 +3,7 @@ package dbml
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||
@@ -842,9 +843,9 @@ func TestConstraintNaming(t *testing.T) {
|
||||
t.Logf("Available constraints: %v", getKeys(postsTable.Constraints))
|
||||
}
|
||||
|
||||
// Test unique index naming: uidx_table_columns
|
||||
if _, exists := postsTable.Indexes["uidx_posts_slug"]; !exists {
|
||||
t.Error("Expected unique index 'uidx_posts_slug' not found")
|
||||
// Test unique index naming: ukey_table_columns, matching the unique constraint name
|
||||
if _, exists := postsTable.Indexes["ukey_posts_slug"]; !exists {
|
||||
t.Error("Expected unique index 'ukey_posts_slug' not found")
|
||||
t.Logf("Available indexes: %v", getKeys(postsTable.Indexes))
|
||||
}
|
||||
|
||||
@@ -1085,3 +1086,105 @@ func TestReader_MultilineTableNote(t *testing.T) {
|
||||
t.Errorf("column note = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// A name reused inside one Indexes block must not silently drop the earlier
|
||||
// index; both are kept so the inspector can report the duplicate.
|
||||
func TestReader_DuplicateIndexNameInTableKept(t *testing.T) {
|
||||
dbmlContent := `Table individual_actor {
|
||||
id bigint [pk]
|
||||
rid_actor bigint
|
||||
kind text
|
||||
|
||||
Indexes {
|
||||
(rid_actor) [name: 'idx_actor', unique]
|
||||
(rid_actor, kind) [name: 'idx_actor']
|
||||
}
|
||||
}
|
||||
`
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "dup_index.dbml")
|
||||
if err := os.WriteFile(path, []byte(dbmlContent), 0o644); err != nil {
|
||||
t.Fatalf("failed to write fixture: %v", err)
|
||||
}
|
||||
|
||||
db, err := NewReader(&readers.ReaderOptions{FilePath: path}).ReadDatabase()
|
||||
if err != nil {
|
||||
t.Fatalf("ReadDatabase() error = %v", err)
|
||||
}
|
||||
|
||||
table := db.Schemas[0].Tables[0]
|
||||
if len(table.Indexes) != 2 {
|
||||
t.Fatalf("expected 2 indexes, got %d: %v", len(table.Indexes), table.Indexes)
|
||||
}
|
||||
for key, idx := range table.Indexes {
|
||||
if idx.Name != "idx_actor" {
|
||||
t.Errorf("index %q: Name = %q, want idx_actor", key, idx.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReader_ForeignKeyDeferral(t *testing.T) {
|
||||
dbml := `Table users {
|
||||
id integer [pk]
|
||||
}
|
||||
|
||||
Table posts {
|
||||
id integer [pk]
|
||||
user_id integer [ref: > users.id, deferrable, initially deferred]
|
||||
editor_id integer
|
||||
plain_id integer [ref: > users.id]
|
||||
}
|
||||
|
||||
Table comments {
|
||||
id integer [pk]
|
||||
post_id integer
|
||||
author_id integer
|
||||
}
|
||||
|
||||
Ref: comments.post_id > posts.id [delete: cascade, deferrable]
|
||||
Ref: comments.author_id > users.id [deferrable, initially deferred]
|
||||
`
|
||||
path := filepath.Join(t.TempDir(), "deferral.dbml")
|
||||
if err := os.WriteFile(path, []byte(dbml), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
db, err := NewReader(&readers.ReaderOptions{FilePath: path}).ReadDatabase()
|
||||
if err != nil {
|
||||
t.Fatalf("ReadDatabase() error = %v", err)
|
||||
}
|
||||
|
||||
find := func(table, column string) *models.Constraint {
|
||||
for _, s := range db.Schemas {
|
||||
for _, tb := range s.Tables {
|
||||
if tb.Name != table {
|
||||
continue
|
||||
}
|
||||
for _, c := range tb.Constraints {
|
||||
if c.Type == models.ForeignKeyConstraint && len(c.Columns) == 1 && c.Columns[0] == column {
|
||||
return c
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
t.Fatalf("FK %s.%s not found", table, column)
|
||||
return nil
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
table, column string
|
||||
want string
|
||||
}{
|
||||
{"posts", "user_id", "DEFERRABLE INITIALLY DEFERRED"},
|
||||
{"posts", "plain_id", ""},
|
||||
{"comments", "post_id", "DEFERRABLE"},
|
||||
{"comments", "author_id", "DEFERRABLE INITIALLY DEFERRED"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := find(tt.table, tt.column).DeferralClause(); got != tt.want {
|
||||
t.Errorf("%s.%s deferral = %q, want %q", tt.table, tt.column, got, tt.want)
|
||||
}
|
||||
}
|
||||
if got := find("comments", "post_id").OnDelete; !strings.EqualFold(got, "cascade") {
|
||||
t.Errorf("delete action lost, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,6 +85,7 @@ export const postsRelations = relations(posts, ({ one }) => ({
|
||||
|
||||
## Notes
|
||||
|
||||
- `.generatedAlwaysAs(sql`expr`)` sets `Generated` + `GenerationExpression`; `.generatedAlwaysAsIdentity()` / `.generatedByDefaultAsIdentity()` set `Identity` + `IdentityGeneration`
|
||||
- Supports both PostgreSQL and MySQL Drizzle schemas
|
||||
- Extracts relationship information from `relations` definitions
|
||||
- Schema defaults to `public` for PostgreSQL
|
||||
|
||||
@@ -15,6 +15,9 @@ import (
|
||||
// Reader implements the readers.Reader interface for Drizzle schema format
|
||||
type Reader struct {
|
||||
options *readers.ReaderOptions
|
||||
// enumVars maps the constant a pgEnum() is assigned to (e.g. "role") to the
|
||||
// enum's SQL name (e.g. "Role"), so columns declared as role('col') resolve.
|
||||
enumVars map[string]string
|
||||
}
|
||||
|
||||
// NewReader creates a new Drizzle reader with the given options
|
||||
@@ -29,6 +32,7 @@ func (r *Reader) ReadDatabase() (*models.Database, error) {
|
||||
if r.options.FilePath == "" {
|
||||
return nil, fmt.Errorf("file path is required for Drizzle reader")
|
||||
}
|
||||
r.enumVars = make(map[string]string)
|
||||
|
||||
// Check if it's a file or directory
|
||||
info, err := os.Stat(r.options.FilePath)
|
||||
@@ -100,6 +104,13 @@ func (r *Reader) readDirectory(dirPath string) (*models.Database, error) {
|
||||
return nil, fmt.Errorf("failed to glob directory: %w", err)
|
||||
}
|
||||
|
||||
// Enums may be declared in a different file than the tables using them
|
||||
for _, file := range files {
|
||||
if content, err := os.ReadFile(file); err == nil {
|
||||
r.collectEnumVars(string(content))
|
||||
}
|
||||
}
|
||||
|
||||
// Parse each file
|
||||
for _, file := range files {
|
||||
content, err := os.ReadFile(file)
|
||||
@@ -125,9 +136,22 @@ func (r *Reader) readDirectory(dirPath string) (*models.Database, error) {
|
||||
return db, nil
|
||||
}
|
||||
|
||||
var enumVarRegex = regexp.MustCompile(`export\s+const\s+(\w+)\s*=\s*pgEnum\s*\(\s*['"](\w+)['"]`)
|
||||
|
||||
// collectEnumVars records every pgEnum() constant declared in content.
|
||||
func (r *Reader) collectEnumVars(content string) {
|
||||
if r.enumVars == nil {
|
||||
r.enumVars = make(map[string]string)
|
||||
}
|
||||
for _, m := range enumVarRegex.FindAllStringSubmatch(content, -1) {
|
||||
r.enumVars[m[1]] = m[2]
|
||||
}
|
||||
}
|
||||
|
||||
// parseDrizzle parses Drizzle schema content and returns a Database model
|
||||
func (r *Reader) parseDrizzle(content string) (*models.Database, error) {
|
||||
db := models.InitDatabase("database")
|
||||
r.collectEnumVars(content)
|
||||
|
||||
if r.options.Metadata != nil {
|
||||
if name, ok := r.options.Metadata["name"].(string); ok {
|
||||
@@ -375,6 +399,9 @@ func (r *Reader) parseColumnDefinition(line, fieldName, drizzleType string, tabl
|
||||
|
||||
// Map Drizzle type to SQL type
|
||||
column.Type = r.drizzleTypeToSQL(drizzleType)
|
||||
if enumName, ok := r.enumVars[drizzleType]; ok {
|
||||
column.Type = enumName
|
||||
}
|
||||
|
||||
// Default: columns are nullable unless specified
|
||||
column.NotNull = false
|
||||
@@ -495,9 +522,24 @@ func (r *Reader) parseColumnModifiers(line string, column *models.Column, table
|
||||
}
|
||||
}
|
||||
|
||||
// Check for .generatedAlwaysAsIdentity()
|
||||
// Check for .generatedAlwaysAsIdentity() / .generatedByDefaultAsIdentity()
|
||||
if strings.Contains(line, ".generatedAlwaysAsIdentity()") {
|
||||
column.AutoIncrement = true
|
||||
column.Identity = true
|
||||
column.IdentityGeneration = "ALWAYS"
|
||||
}
|
||||
if strings.Contains(line, ".generatedByDefaultAsIdentity()") {
|
||||
column.AutoIncrement = true
|
||||
column.Identity = true
|
||||
column.IdentityGeneration = "BY DEFAULT"
|
||||
}
|
||||
|
||||
// Check for .generatedAlwaysAs(sql`expr`) (generated column)
|
||||
if idx := strings.Index(line, ".generatedAlwaysAs("); idx != -1 {
|
||||
if expr, ok := parseGeneratedAlwaysAs(line[idx+len(".generatedAlwaysAs("):]); ok {
|
||||
column.Generated = true
|
||||
column.GenerationExpression = expr
|
||||
}
|
||||
}
|
||||
|
||||
// Check for .references(() => otherTable.column)
|
||||
@@ -615,3 +657,34 @@ func (r *Reader) varNameToTableName(varName string) string {
|
||||
// For now, assume variable name matches table name
|
||||
return varName
|
||||
}
|
||||
|
||||
// parseGeneratedAlwaysAs extracts the SQL expression from the argument list of
|
||||
// generatedAlwaysAs(...), which follows the opening parenthesis in rest. The expression is
|
||||
// the contents of a sql`...` template (backticks inside it are escaped with a backslash)
|
||||
// or, failing that, a quoted string.
|
||||
func parseGeneratedAlwaysAs(rest string) (string, bool) {
|
||||
rest = strings.TrimSpace(rest)
|
||||
rest = strings.TrimPrefix(rest, "sql")
|
||||
if rest == "" {
|
||||
return "", false
|
||||
}
|
||||
quote := rest[0]
|
||||
if quote != '`' && quote != '\'' && quote != '"' {
|
||||
return "", false
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
for i := 1; i < len(rest); i++ {
|
||||
ch := rest[i]
|
||||
if ch == '\\' && i+1 < len(rest) {
|
||||
i++
|
||||
sb.WriteByte(rest[i])
|
||||
continue
|
||||
}
|
||||
if ch == quote {
|
||||
return strings.TrimSpace(sb.String()), true
|
||||
}
|
||||
sb.WriteByte(ch)
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
package drizzle
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers"
|
||||
)
|
||||
|
||||
const fixture = "../../../tests/assets/drizzle/schema.ts"
|
||||
|
||||
func readFile(t *testing.T, path string) *models.Database {
|
||||
t.Helper()
|
||||
db, err := NewReader(&readers.ReaderOptions{FilePath: path}).ReadDatabase()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func findTable(db *models.Database, name string) *models.Table {
|
||||
for _, s := range db.Schemas {
|
||||
for _, tb := range s.Tables {
|
||||
if tb.Name == name {
|
||||
return tb
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestReadFixture(t *testing.T) {
|
||||
db := readFile(t, fixture)
|
||||
if len(db.Schemas) == 0 || len(db.Schemas[0].Tables) == 0 {
|
||||
t.Fatal("expected tables")
|
||||
}
|
||||
if len(db.Schemas[0].Enums) != 1 || db.Schemas[0].Enums[0].Name != "Role" {
|
||||
t.Fatalf("enums = %+v", db.Schemas[0].Enums)
|
||||
}
|
||||
var found bool
|
||||
for _, tb := range db.Schemas[0].Tables {
|
||||
if c, ok := tb.Columns["role"]; ok {
|
||||
found = true
|
||||
if c.Type != "Role" {
|
||||
t.Errorf("role type = %q", c.Type)
|
||||
}
|
||||
}
|
||||
for n := range tb.Columns {
|
||||
if n == "profile" {
|
||||
t.Errorf("relation field leaked as column in %s", tb.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("no role column")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnumColumnSyntax(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
src string
|
||||
}{
|
||||
{"enum constant", "export const role = pgEnum('Role', ['A','B']);\nexport const users = pgTable('users', {\n role: role('role').notNull(),\n});\n"},
|
||||
{"legacy", "export const role = pgEnum('Role', ['A','B']);\nexport const users = pgTable('users', {\n role: pgEnum('Role')('role').notNull(),\n});\n"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
p := filepath.Join(t.TempDir(), "s.ts")
|
||||
if err := os.WriteFile(p, []byte(tt.src), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tb := findTable(readFile(t, p), "users")
|
||||
if tb == nil {
|
||||
t.Fatal("users missing")
|
||||
}
|
||||
c := tb.Columns["role"]
|
||||
if c == nil || c.Type != "Role" || !c.NotNull {
|
||||
t.Errorf("column = %+v", c)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadDirectorySeparateEnums(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
files := map[string]string{
|
||||
"enums.ts": "export const status = pgEnum('Status', ['on','off']);\n",
|
||||
"tables.ts": "export const items = pgTable('items', {\n status: status('status'),\n});\n",
|
||||
}
|
||||
for n, c := range files {
|
||||
if err := os.WriteFile(filepath.Join(dir, n), []byte(c), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
tb := findTable(readFile(t, dir), "items")
|
||||
if tb == nil {
|
||||
t.Fatal("items missing")
|
||||
}
|
||||
if c := tb.Columns["status"]; c == nil || c.Type != "Status" {
|
||||
t.Errorf("column = %+v", c)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReaderErrors(t *testing.T) {
|
||||
if _, err := NewReader(&readers.ReaderOptions{}).ReadDatabase(); err == nil {
|
||||
t.Error("expected error for empty path")
|
||||
}
|
||||
if _, err := NewReader(&readers.ReaderOptions{FilePath: "/nonexistent.ts"}).ReadDatabase(); err == nil {
|
||||
t.Error("expected error for missing file")
|
||||
}
|
||||
}
|
||||
@@ -78,6 +78,8 @@ The reader recognizes the following GORM struct tags:
|
||||
- `not null` - NOT NULL constraint
|
||||
- `autoIncrement` - Auto-increment column
|
||||
- `default` - Default value
|
||||
- `generated` - Generated column marker (`Generated`; expression is not in the tag)
|
||||
- `identity` - Identity column marker (`Identity`, `ALWAYS`)
|
||||
- `size` - Column size/length
|
||||
- `index` - Create index
|
||||
- `uniqueIndex` - Create unique index
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package gorm
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers"
|
||||
)
|
||||
|
||||
func TestReader_GeneratedAndIdentityMarkers(t *testing.T) {
|
||||
src := `package models
|
||||
|
||||
type Person struct {
|
||||
ID int64 ` + "`gorm:\"column:id;primaryKey;autoIncrement;type:bigint;identity\"`" + `
|
||||
Seq int64 ` + "`gorm:\"column:seq;type:bigint;<-:false;identity;not null\"`" + `
|
||||
FullName string ` + "`gorm:\"column:full_name;type:text;<-:false;generated\"`" + `
|
||||
First string ` + "`gorm:\"column:first;type:text\"`" + `
|
||||
}
|
||||
|
||||
func (Person) TableName() string {
|
||||
return "people"
|
||||
}
|
||||
`
|
||||
path := filepath.Join(t.TempDir(), "person.go")
|
||||
if err := os.WriteFile(path, []byte(src), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
db, err := NewReader(&readers.ReaderOptions{FilePath: path}).ReadDatabase()
|
||||
if err != nil {
|
||||
t.Fatalf("ReadDatabase() error = %v", err)
|
||||
}
|
||||
cols := db.Schemas[0].Tables[0].Columns
|
||||
|
||||
if !cols["id"].Identity || cols["id"].IdentityGeneration != "ALWAYS" {
|
||||
t.Errorf("id: identity = %v/%q, want true/ALWAYS", cols["id"].Identity, cols["id"].IdentityGeneration)
|
||||
}
|
||||
if !cols["seq"].Identity {
|
||||
t.Error("seq should be identity")
|
||||
}
|
||||
if !cols["full_name"].Generated {
|
||||
t.Error("full_name should be generated")
|
||||
}
|
||||
if cols["first"].Generated || cols["first"].Identity {
|
||||
t.Error("first must be an ordinary column")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package gorm
|
||||
|
||||
import (
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"testing"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers"
|
||||
)
|
||||
|
||||
func newTestReader() *Reader { return NewReader(&readers.ReaderOptions{}) }
|
||||
|
||||
func mustExpr(t *testing.T, src string) ast.Expr {
|
||||
t.Helper()
|
||||
e, err := parser.ParseExpr(src)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
func TestGoTypeToSQL(t *testing.T) {
|
||||
r := newTestReader()
|
||||
tests := []struct{ src, want string }{
|
||||
{"int", "integer"},
|
||||
{"int32", "integer"},
|
||||
{"int64", "bigint"},
|
||||
{"string", "text"},
|
||||
{"bool", "boolean"},
|
||||
{"float32", "real"},
|
||||
{"float64", "double precision"},
|
||||
{"uint8", "text"},
|
||||
{"time.Time", "timestamp"},
|
||||
{"time.Duration", "text"},
|
||||
{"sql_types.SqlString", "text"},
|
||||
{"sql_types.SqlInt", "integer"},
|
||||
{"sql_types.SqlInt64", "bigint"},
|
||||
{"sql_types.SqlFloat", "double precision"},
|
||||
{"sql_types.SqlBool", "boolean"},
|
||||
{"sql_types.SqlTime", "timestamp"},
|
||||
{"sql_types.Other", "text"},
|
||||
{"other.Thing", "text"},
|
||||
{"*int64", "bigint"},
|
||||
{"*time.Time", "timestamp"},
|
||||
{"[]byte", "text"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.src, func(t *testing.T) {
|
||||
if got := r.goTypeToSQL(mustExpr(t, tt.src)); got != tt.want {
|
||||
t.Errorf("got %q want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFieldNameToColumnName(t *testing.T) {
|
||||
r := newTestReader()
|
||||
for in, want := range map[string]string{"ID": "i_d", "UserName": "user_name", "name": "name", "": ""} {
|
||||
if got := r.fieldNameToColumnName(in); got != want {
|
||||
t.Errorf("%q: got %q want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetReceiverType(t *testing.T) {
|
||||
r := newTestReader()
|
||||
tests := []struct{ src, want string }{
|
||||
{"User", "User"}, {"*User", "User"}, {"*pkg.User", ""}, {"[]User", ""},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := r.getReceiverType(mustExpr(t, tt.src)); got != tt.want {
|
||||
t.Errorf("%s: got %q want %q", tt.src, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsGORMModel(t *testing.T) {
|
||||
r := newTestReader()
|
||||
tests := []struct {
|
||||
name string
|
||||
field *ast.Field
|
||||
want bool
|
||||
}{
|
||||
{"embedded gorm.Model", &ast.Field{Type: mustExpr(t, "gorm.Model")}, true},
|
||||
{"named field", &ast.Field{Names: []*ast.Ident{ast.NewIdent("M")}, Type: mustExpr(t, "gorm.Model")}, false},
|
||||
{"plain ident", &ast.Field{Type: mustExpr(t, "Model")}, false},
|
||||
{"other package", &ast.Field{Type: mustExpr(t, "other.Model")}, false},
|
||||
{"gorm other", &ast.Field{Type: mustExpr(t, "gorm.DB")}, false},
|
||||
{"non-ident selector base", &ast.Field{Type: mustExpr(t, "a.b.Model")}, false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := r.isGORMModel(tt.field); got != tt.want {
|
||||
t.Errorf("got %v want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTypeWithReferences(t *testing.T) {
|
||||
r := newTestReader()
|
||||
tests := []struct {
|
||||
in string
|
||||
base string
|
||||
length int
|
||||
refInfo string
|
||||
}{
|
||||
{"bigint", "bigint", 0, ""},
|
||||
{"varchar(50)", "varchar", 50, ""},
|
||||
{"bigint references mainaccount(id) ON DELETE CASCADE", "bigint", 0, "mainaccount(id) ON DELETE CASCADE"},
|
||||
{"varchar(20) REFERENCES t(c)", "varchar", 20, "t(c)"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
base, length, ref := r.parseTypeWithReferences(tt.in)
|
||||
if base != tt.base || length != tt.length || ref != tt.refInfo {
|
||||
t.Errorf("%q: got (%q,%d,%q)", tt.in, base, length, ref)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateInlineReferenceConstraint(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
ref string
|
||||
wantNone bool
|
||||
schema string
|
||||
table string
|
||||
col string
|
||||
onDelete string
|
||||
onUpdate string
|
||||
}{
|
||||
{"simple", "accounts(id)", false, "public", "accounts", "id", "NO ACTION", "NO ACTION"},
|
||||
{"schema qualified", "billing.accounts(id)", false, "billing", "accounts", "id", "NO ACTION", "NO ACTION"},
|
||||
{"cascade restrict", "accounts(id) ON DELETE CASCADE ON UPDATE RESTRICT", false, "public", "accounts", "id", "CASCADE", "RESTRICT"},
|
||||
{"set null no action", "accounts(id) on delete set null on update no action", false, "public", "accounts", "id", "SET NULL", "NO ACTION"},
|
||||
{"restrict delete cascade update", "accounts(id) ON DELETE RESTRICT ON UPDATE CASCADE", false, "public", "accounts", "id", "RESTRICT", "CASCADE"},
|
||||
{"update set null", "accounts(id) ON DELETE NO ACTION ON UPDATE SET NULL", false, "public", "accounts", "id", "NO ACTION", "SET NULL"},
|
||||
{"no parens", "accounts", true, "", "", "", "", ""},
|
||||
{"reversed parens", "accounts)id(", true, "", "", "", "", ""},
|
||||
}
|
||||
r := newTestReader()
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
table := models.InitTable("orders", "public")
|
||||
col := models.InitColumn("account_id", "orders", "public")
|
||||
r.createInlineReferenceConstraint(table, col, tt.ref)
|
||||
if tt.wantNone {
|
||||
if len(table.Constraints) != 0 {
|
||||
t.Fatalf("unexpected constraints: %v", table.Constraints)
|
||||
}
|
||||
return
|
||||
}
|
||||
c := table.Constraints["fk_orders_account_id"]
|
||||
if c == nil {
|
||||
t.Fatal("constraint missing")
|
||||
}
|
||||
if c.ReferencedSchema != tt.schema || c.ReferencedTable != tt.table ||
|
||||
c.ReferencedColumns[0] != tt.col || c.OnDelete != tt.onDelete || c.OnUpdate != tt.onUpdate {
|
||||
t.Errorf("constraint = %+v", c)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -726,6 +726,15 @@ func (r *Reader) parseColumn(fieldName string, fieldType ast.Expr, tag string, s
|
||||
if _, ok := parts["autoincrement"]; ok {
|
||||
column.AutoIncrement = true
|
||||
}
|
||||
if _, ok := parts["generated"]; ok {
|
||||
// GENERATED ... STORED marker written by the GORM writer; the expression is not in the tag
|
||||
column.Generated = true
|
||||
}
|
||||
if _, ok := parts["identity"]; ok {
|
||||
// GENERATED ALWAYS AS IDENTITY marker written by the GORM writer
|
||||
column.Identity = true
|
||||
column.IdentityGeneration = "ALWAYS"
|
||||
}
|
||||
if def, ok := parts["default"]; ok {
|
||||
// Default value from GORM tag (e.g., default:gen_random_uuid())
|
||||
column.Default = def
|
||||
|
||||
@@ -25,6 +25,11 @@ sqlserver://user:pass@192.168.1.100:1433/production
|
||||
sqlserver://localhost/testdb?encrypt=disable
|
||||
```
|
||||
|
||||
## Computed and Identity Columns
|
||||
|
||||
- `sys.computed_columns` -> `Generated` + `GenerationExpression` (outer parentheses removed; persisted or not)
|
||||
- Identity columns -> `Identity` + `IdentityGeneration = ALWAYS`
|
||||
|
||||
## Supported Constraints
|
||||
|
||||
- Primary Keys
|
||||
|
||||
@@ -104,8 +104,12 @@ func (r *Reader) queryColumns(schemaName string) (map[string]map[string]*models.
|
||||
c.numeric_precision,
|
||||
c.numeric_scale,
|
||||
ISNULL(ep.value, '') as description,
|
||||
COLUMNPROPERTY(OBJECT_ID(QUOTENAME(c.table_schema) + '.' + QUOTENAME(c.table_name)), c.column_name, 'IsIdentity') as is_identity
|
||||
COLUMNPROPERTY(OBJECT_ID(QUOTENAME(c.table_schema) + '.' + QUOTENAME(c.table_name)), c.column_name, 'IsIdentity') as is_identity,
|
||||
cc.definition as computed_definition
|
||||
FROM information_schema.columns c
|
||||
LEFT JOIN sys.computed_columns cc
|
||||
ON cc.object_id = OBJECT_ID(QUOTENAME(c.table_schema) + '.' + QUOTENAME(c.table_name))
|
||||
AND cc.name = c.column_name
|
||||
LEFT JOIN sys.extended_properties ep
|
||||
ON ep.major_id = OBJECT_ID(QUOTENAME(c.table_schema) + '.' + QUOTENAME(c.table_name))
|
||||
AND ep.minor_id = COLUMNPROPERTY(OBJECT_ID(QUOTENAME(c.table_schema) + '.' + QUOTENAME(c.table_name)), c.column_name, 'ColumnId')
|
||||
@@ -127,8 +131,9 @@ func (r *Reader) queryColumns(schemaName string) (map[string]map[string]*models.
|
||||
var schema, tableName, columnName, isNullable, dataType, description string
|
||||
var ordinalPosition int
|
||||
var columnDefault, charMaxLength, numPrecision, numScale, isIdentity *int
|
||||
var computedDefinition *string
|
||||
|
||||
if err := rows.Scan(&schema, &tableName, &columnName, &ordinalPosition, &columnDefault, &isNullable, &dataType, &charMaxLength, &numPrecision, &numScale, &description, &isIdentity); err != nil {
|
||||
if err := rows.Scan(&schema, &tableName, &columnName, &ordinalPosition, &columnDefault, &isNullable, &dataType, &charMaxLength, &numPrecision, &numScale, &description, &isIdentity, &computedDefinition); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -144,6 +149,14 @@ func (r *Reader) queryColumns(schemaName string) (map[string]map[string]*models.
|
||||
// Check if this is an identity column (auto-increment)
|
||||
if isIdentity != nil && *isIdentity == 1 {
|
||||
column.AutoIncrement = true
|
||||
column.Identity = true
|
||||
column.IdentityGeneration = "ALWAYS"
|
||||
}
|
||||
|
||||
// Computed columns report their expression wrapped in an extra pair of parentheses
|
||||
if computedDefinition != nil && strings.TrimSpace(*computedDefinition) != "" {
|
||||
column.Generated = true
|
||||
column.GenerationExpression = trimOuterParens(*computedDefinition)
|
||||
}
|
||||
|
||||
if charMaxLength != nil && *charMaxLength > 0 {
|
||||
@@ -414,3 +427,25 @@ func (r *Reader) queryIndexes(schemaName string) (map[string][]*models.Index, er
|
||||
|
||||
return indexes, rows.Err()
|
||||
}
|
||||
|
||||
// trimOuterParens removes one pair of parentheses wrapping the whole expression, as
|
||||
// SQL Server stores computed column definitions.
|
||||
func trimOuterParens(expr string) string {
|
||||
expr = strings.TrimSpace(expr)
|
||||
if len(expr) < 2 || expr[0] != '(' || expr[len(expr)-1] != ')' {
|
||||
return expr
|
||||
}
|
||||
depth := 0
|
||||
for i := 0; i < len(expr); i++ {
|
||||
switch expr[i] {
|
||||
case '(':
|
||||
depth++
|
||||
case ')':
|
||||
depth--
|
||||
if depth == 0 && i != len(expr)-1 {
|
||||
return expr
|
||||
}
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(expr[1 : len(expr)-1])
|
||||
}
|
||||
|
||||
@@ -85,3 +85,18 @@ func TestConvertMSSQLToCanonical(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrimOuterParens(t *testing.T) {
|
||||
tests := map[string]string{
|
||||
"(([a])+([b]))": "([a])+([b])",
|
||||
"([a]+[b])": "[a]+[b]",
|
||||
"([a])+([b])": "([a])+([b])",
|
||||
"[a]+[b]": "[a]+[b]",
|
||||
" (upper([a])) ": "upper([a])",
|
||||
}
|
||||
for in, want := range tests {
|
||||
if got := trimOuterParens(in); got != want {
|
||||
t.Errorf("trimOuterParens(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// columnDefaults extracts native DEFAULT expressions from SHOW CREATE TABLE.
|
||||
// MySQL can truncate binary defaults in information_schema (for example X'00FF'
|
||||
// appears as just "0x"). SHOW CREATE is authoritative for their SQL spelling.
|
||||
// Separators and whitespace inside quoted strings, identifiers and nested
|
||||
// expressions are never treated as definition/default boundaries.
|
||||
func columnDefaults(create string) map[string]string {
|
||||
out := map[string]string{}
|
||||
depth, start := 0, 0
|
||||
var quoted byte
|
||||
definitions := []string{}
|
||||
for i := 0; i < len(create); i++ {
|
||||
ch := create[i]
|
||||
if quoted != 0 {
|
||||
if ch == '\\' && quoted != '`' && i+1 < len(create) {
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if ch == quoted {
|
||||
if i+1 < len(create) && create[i+1] == quoted {
|
||||
i++
|
||||
} else {
|
||||
quoted = 0
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
if ch == '\'' || ch == '"' || ch == '`' {
|
||||
quoted = ch
|
||||
continue
|
||||
}
|
||||
switch ch {
|
||||
case '(':
|
||||
depth++
|
||||
if depth == 1 {
|
||||
start = i + 1
|
||||
}
|
||||
case ',':
|
||||
if depth == 1 {
|
||||
definitions = append(definitions, create[start:i])
|
||||
start = i + 1
|
||||
}
|
||||
case ')':
|
||||
depth--
|
||||
if depth == 0 {
|
||||
definitions = append(definitions, create[start:i])
|
||||
}
|
||||
}
|
||||
if ch == ')' && depth == 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
for _, def := range definitions {
|
||||
def = strings.TrimSpace(def)
|
||||
if len(def) == 0 || def[0] != '`' {
|
||||
continue
|
||||
}
|
||||
var name strings.Builder
|
||||
end := 1
|
||||
for end < len(def) {
|
||||
if def[end] == '`' {
|
||||
if end+1 < len(def) && def[end+1] == '`' {
|
||||
name.WriteByte('`')
|
||||
end += 2
|
||||
continue
|
||||
}
|
||||
end++
|
||||
break
|
||||
}
|
||||
name.WriteByte(def[end])
|
||||
end++
|
||||
}
|
||||
// Consume full tokens so DEFAULT inside a generated expression or literal
|
||||
// cannot masquerade as a column default.
|
||||
for end < len(def) {
|
||||
for end < len(def) && isSpace(def[end]) {
|
||||
end++
|
||||
}
|
||||
begin := end
|
||||
end = tokenEnd(def, end)
|
||||
if end == begin {
|
||||
break
|
||||
}
|
||||
if strings.EqualFold(def[begin:end], "DEFAULT") {
|
||||
for end < len(def) && isSpace(def[end]) {
|
||||
end++
|
||||
}
|
||||
finish := tokenEnd(def, end)
|
||||
out[name.String()] = def[end:finish]
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
func isSpace(b byte) bool { return b == ' ' || b == '\n' || b == '\r' || b == '\t' }
|
||||
func tokenEnd(s string, start int) int {
|
||||
depth := 0
|
||||
var quoted byte
|
||||
for i := start; i < len(s); i++ {
|
||||
ch := s[i]
|
||||
if quoted != 0 {
|
||||
if ch == '\\' && quoted != '`' && i+1 < len(s) {
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if ch == quoted {
|
||||
if i+1 < len(s) && s[i+1] == quoted {
|
||||
i++
|
||||
} else {
|
||||
quoted = 0
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
if ch == '\'' || ch == '"' || ch == '`' {
|
||||
quoted = ch
|
||||
continue
|
||||
}
|
||||
if ch == '(' {
|
||||
depth++
|
||||
}
|
||||
if ch == ')' {
|
||||
depth--
|
||||
}
|
||||
if depth == 0 && isSpace(ch) {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return len(s)
|
||||
}
|
||||
|
||||
// SHOW CREATE has native options not exposed by STATISTICS/COLUMNS. Ignore
|
||||
// quoted identifiers and values when detecting options outside our contract.
|
||||
func unquotedSQL(create string) string {
|
||||
var code strings.Builder
|
||||
var quoted byte
|
||||
for i := 0; i < len(create); i++ {
|
||||
ch := create[i]
|
||||
if quoted != 0 {
|
||||
if ch == '\\' && quoted != '`' && i+1 < len(create) {
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if ch == quoted {
|
||||
if i+1 < len(create) && create[i+1] == quoted {
|
||||
i++
|
||||
} else {
|
||||
quoted = 0
|
||||
code.WriteByte(' ')
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
if ch == '\'' || ch == '"' || ch == '`' {
|
||||
quoted = ch
|
||||
code.WriteByte(' ')
|
||||
continue
|
||||
}
|
||||
code.WriteByte(ch)
|
||||
}
|
||||
return code.String()
|
||||
}
|
||||
|
||||
var autoIncrementOption = regexp.MustCompile(`(?i)\bAUTO_INCREMENT\s*=\s*([0-9]+)`)
|
||||
|
||||
func autoIncrementValue(create string) string {
|
||||
if match := autoIncrementOption.FindStringSubmatch(unquotedSQL(create)); len(match) > 1 {
|
||||
return match[1]
|
||||
}
|
||||
return "1"
|
||||
}
|
||||
|
||||
func unsupportedNativeOptions(create string) []string {
|
||||
words := strings.FieldsFunc(strings.ToUpper(unquotedSQL(create)), func(r rune) bool { return (r < 'A' || r > 'Z') && r != '_' })
|
||||
var out []string
|
||||
for i, w := range words {
|
||||
switch w {
|
||||
case "ENGINE_ATTRIBUTE", "SECONDARY_ENGINE_ATTRIBUTE", "KEY_BLOCK_SIZE", "COLUMN_FORMAT", "STORAGE":
|
||||
out = append(out, w)
|
||||
case "PARSER":
|
||||
if i > 0 && words[i-1] == "WITH" {
|
||||
out = append(out, "WITH PARSER")
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package mysql
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestUnescapeExpression(t *testing.T) {
|
||||
for _, tc := range []struct{ input, want string }{
|
||||
{`_utf8mb4\'one\\ntwo\'`, `_utf8mb4'one\ntwo'`},
|
||||
{`concat(\'don\\\'t\',\' change\')`, `concat('don\'t',' change')`},
|
||||
{`(uuid())`, `(uuid())`},
|
||||
} {
|
||||
if got := unescapeExpression(tc.input); got != tc.want {
|
||||
t.Errorf("%q: got %q want %q", tc.input, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNativeColumnDefaults(t *testing.T) {
|
||||
create := "CREATE TABLE `odd(table` (\n" +
|
||||
" `a,b` varbinary(8) DEFAULT 0x0027FF,\n" +
|
||||
" `tick``name` varchar(30) DEFAULT 'don''t, (split)',\n" +
|
||||
" `expr` varchar(30) DEFAULT (concat('DEFAULT, ', (uuid()))),\n" +
|
||||
" `generated` varchar(30) GENERATED ALWAYS AS (concat('DEFAULT',`expr`)) VIRTUAL,\n" +
|
||||
" `new\nline` int DEFAULT 3,\n" +
|
||||
" PRIMARY KEY (`a,b`)\n) ENGINE=InnoDB"
|
||||
got := columnDefaults(create)
|
||||
want := map[string]string{"a,b": "0x0027FF", "tick`name": "'don''t, (split)'", "expr": "(concat('DEFAULT, ', (uuid())))", "new\nline": "3"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("defaults: %v", got)
|
||||
}
|
||||
for name, value := range want {
|
||||
if got[name] != value {
|
||||
t.Errorf("%s: got %q want %q", name, got[name], value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNativeOptionsIgnoreQuotedValues(t *testing.T) {
|
||||
create := "CREATE TABLE `WITH PARSER` (`STORAGE` int COMMENT 'KEY_BLOCK_SIZE', KEY `ENGINE_ATTRIBUTE` (`STORAGE`) WITH PARSER `ngram`) ENGINE=InnoDB"
|
||||
options := unsupportedNativeOptions(create)
|
||||
if len(options) != 1 || options[0] != "WITH PARSER" {
|
||||
t.Fatalf("native options: %v", options)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNativeAutoIncrementIgnoresQuotedValues(t *testing.T) {
|
||||
for _, tc := range []struct{ create, want string }{
|
||||
{"CREATE TABLE `x` (`id` bigint AUTO_INCREMENT) ENGINE=InnoDB AUTO_INCREMENT=18446744073709551615", "18446744073709551615"},
|
||||
{"CREATE TABLE `x` (`id` bigint AUTO_INCREMENT) COMMENT='AUTO_INCREMENT=12'", "1"},
|
||||
} {
|
||||
if got := autoIncrementValue(tc.create); got != tc.want {
|
||||
t.Errorf("got %s want %s", got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,463 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
mysqlDriver "github.com/go-sql-driver/mysql"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/mariadb"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers"
|
||||
)
|
||||
|
||||
type Reader struct {
|
||||
options *readers.ReaderOptions
|
||||
db *sql.DB
|
||||
ctx context.Context
|
||||
}
|
||||
|
||||
func NewReader(options *readers.ReaderOptions) *Reader {
|
||||
return &Reader{options: options, ctx: context.Background()}
|
||||
}
|
||||
|
||||
func (r *Reader) ReadDatabase() (*models.Database, error) {
|
||||
if r.options == nil || r.options.ConnectionString == "" {
|
||||
return nil, fmt.Errorf("connection string is required")
|
||||
}
|
||||
if err := r.connect(); err != nil {
|
||||
return nil, fmt.Errorf("failed to connect: %w", err)
|
||||
}
|
||||
defer r.close()
|
||||
var name, version, sqlMode string
|
||||
if err := r.db.QueryRowContext(r.ctx, "SELECT DATABASE(), @@SESSION.sql_mode").Scan(&name, &sqlMode); err != nil {
|
||||
return nil, fmt.Errorf("failed to get database name: %w", err)
|
||||
}
|
||||
_ = r.db.QueryRowContext(r.ctx, "SELECT VERSION()").Scan(&version)
|
||||
db := models.InitDatabase(name)
|
||||
db.Metadata["mysql_sql_mode"] = sqlMode
|
||||
db.DatabaseType, db.SourceFormat, db.DatabaseVersion = models.MySQLDatabaseType, "mysql", version
|
||||
schemas, err := r.querySchemas(name)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query schemas: %w", err)
|
||||
}
|
||||
for _, schema := range schemas {
|
||||
tables, err := r.queryTables(schema.Name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
schema.Tables = tables
|
||||
schema.Metadata["mysql_sql_mode"] = sqlMode
|
||||
for _, table := range tables {
|
||||
table.Metadata["mysql_sql_mode"] = sqlMode
|
||||
table.Columns, err = r.queryColumns(schema.Name, table)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
table.Constraints, err = r.queryConstraints(schema.Name, table.Name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
table.Indexes, err = r.queryIndexes(schema.Name, table.Name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
table.RefSchema = schema
|
||||
for _, c := range table.Constraints {
|
||||
if c.Type == models.PrimaryKeyConstraint {
|
||||
for _, n := range c.Columns {
|
||||
if col := table.Columns[n]; col != nil {
|
||||
col.IsPrimaryKey = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, c := range table.Constraints {
|
||||
if c.Type == models.ForeignKeyConstraint {
|
||||
r.deriveRelationship(table, c)
|
||||
}
|
||||
}
|
||||
}
|
||||
schema.RefDatabase = db
|
||||
db.Schemas = append(db.Schemas, schema)
|
||||
}
|
||||
if err := r.readUnsupported(db, name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return db, nil
|
||||
}
|
||||
|
||||
func (r *Reader) ReadSchema() (*models.Schema, error) {
|
||||
db, err := r.ReadDatabase()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(db.Schemas) == 0 {
|
||||
return nil, fmt.Errorf("no schemas found in database")
|
||||
}
|
||||
return db.Schemas[0], nil
|
||||
}
|
||||
|
||||
func (r *Reader) ReadTable() (*models.Table, error) {
|
||||
s, err := r.ReadSchema()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(s.Tables) == 0 {
|
||||
return nil, fmt.Errorf("no tables found in schema")
|
||||
}
|
||||
return s.Tables[0], nil
|
||||
}
|
||||
|
||||
func (r *Reader) connect() error {
|
||||
cfg, err := mysqlDriver.ParseDSN(r.options.ConnectionString)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if cfg.Params == nil {
|
||||
cfg.Params = map[string]string{}
|
||||
}
|
||||
// SHOW CREATE parsing requires quoted column identifiers, including when the
|
||||
// caller's metadata connection would otherwise disable show-create quoting.
|
||||
cfg.Params["sql_quote_show_create"] = "1"
|
||||
db, err := sql.Open("mysql", cfg.FormatDSN())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err = db.PingContext(r.ctx); err != nil {
|
||||
db.Close()
|
||||
return err
|
||||
}
|
||||
r.db = db
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Reader) close() {
|
||||
if r.db != nil {
|
||||
_ = r.db.Close()
|
||||
}
|
||||
}
|
||||
func (r *Reader) mapDataType(t string) string { return mariadb.ConvertMariaDBToCanonical(t) }
|
||||
|
||||
func (r *Reader) querySchemas(current string) ([]*models.Schema, error) {
|
||||
rows, err := r.db.QueryContext(r.ctx, "SELECT SCHEMA_NAME FROM information_schema.SCHEMATA WHERE SCHEMA_NAME = ?", current)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []*models.Schema
|
||||
for rows.Next() {
|
||||
var n string
|
||||
if err := rows.Scan(&n); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, models.InitSchema(n))
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (r *Reader) queryTables(schema string) ([]*models.Table, error) {
|
||||
rows, err := r.db.QueryContext(r.ctx, "SELECT TABLE_NAME, ENGINE, TABLE_COLLATION, TABLE_COMMENT, CREATE_OPTIONS, AUTO_INCREMENT FROM information_schema.TABLES WHERE TABLE_SCHEMA = ? AND TABLE_TYPE = 'BASE TABLE' ORDER BY TABLE_NAME", schema)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []*models.Table
|
||||
for rows.Next() {
|
||||
var n, engine, collation, comment, options string
|
||||
var auto sql.NullString
|
||||
if err := rows.Scan(&n, &engine, &collation, &comment, &options, &auto); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
t := models.InitTable(n, schema)
|
||||
t.Comment = comment
|
||||
t.Metadata["mysql_engine"], t.Metadata["mysql_collation"], t.Metadata["mysql_create_options"] = engine, collation, options
|
||||
if auto.Valid {
|
||||
t.Metadata["mysql_auto_increment"] = auto.String
|
||||
}
|
||||
out = append(out, t)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
var onUpdateExtra = regexp.MustCompile(`on update current_timestamp(?:\(\d*\))?`)
|
||||
|
||||
func (r *Reader) queryColumns(schema string, table *models.Table) (map[string]*models.Column, error) {
|
||||
rows, err := r.db.QueryContext(r.ctx, `SELECT COLUMN_NAME, COLUMN_TYPE, IS_NULLABLE, COLUMN_DEFAULT, ORDINAL_POSITION, EXTRA, COLUMN_COMMENT,
|
||||
CHARACTER_MAXIMUM_LENGTH, NUMERIC_PRECISION, NUMERIC_SCALE, DATETIME_PRECISION, COLLATION_NAME, CHARACTER_SET_NAME, GENERATION_EXPRESSION, SRS_ID
|
||||
FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=? AND TABLE_NAME=? ORDER BY ORDINAL_POSITION`, schema, table.Name)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("columns %s.%s: %w", schema, table.Name, err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := map[string]*models.Column{}
|
||||
for rows.Next() {
|
||||
var name, typ, nullable, extra, comment string
|
||||
var def, collation, charset, generation sql.NullString
|
||||
var length, precision, scale, datetime, srid sql.NullInt64
|
||||
var pos int
|
||||
if err := rows.Scan(&name, &typ, &nullable, &def, &pos, &extra, &comment, &length, &precision, &scale, &datetime, &collation, &charset, &generation, &srid); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c := models.InitColumn(name, table.Name, schema)
|
||||
c.Type, c.NotNull, c.Sequence, c.Comment = r.mapDataType(typ), nullable == "NO", uint(pos), comment
|
||||
// The native declaration preserves mediumint, enum/set, binary/blob sizes,
|
||||
// unsigned/zerofill and timestamp versus datetime. Canonical dimensions remain editable.
|
||||
c.Length, c.Precision, c.Scale = int(length.Int64), int(precision.Int64), int(scale.Int64)
|
||||
if datetime.Valid {
|
||||
c.Precision = int(datetime.Int64)
|
||||
}
|
||||
c.Collation = collation.String
|
||||
c.Metadata["mysql_type"] = typ
|
||||
c.Metadata["mysql_type_signature"] = typeSignature(c)
|
||||
c.Metadata["mysql_charset"] = charset.String
|
||||
c.Metadata["mysql_datetime_precision"] = datetime.Int64
|
||||
c.Metadata["mysql_default_expression"] = strings.Contains(extra, "DEFAULT_GENERATED") || strings.HasPrefix(strings.ToLower(def.String), "current_timestamp") && (c.Type == "timestamp")
|
||||
if def.Valid {
|
||||
c.Default = def.String
|
||||
if strings.Contains(extra, "DEFAULT_GENERATED") {
|
||||
c.Default = unescapeExpression(def.String)
|
||||
}
|
||||
if strings.HasPrefix(typ, "bit(") && def.Valid {
|
||||
c.Metadata["mysql_default_expression"] = true
|
||||
}
|
||||
}
|
||||
c.AutoIncrement = strings.Contains(extra, "auto_increment")
|
||||
c.Generated, c.GenerationExpression = generation.String != "", unescapeExpression(generation.String)
|
||||
if strings.Contains(extra, "STORED GENERATED") {
|
||||
c.Metadata["mysql_generation_storage"] = "STORED"
|
||||
} else if c.Generated {
|
||||
c.Metadata["mysql_generation_storage"] = "VIRTUAL"
|
||||
}
|
||||
c.Metadata["mysql_invisible"] = strings.Contains(extra, "INVISIBLE")
|
||||
remaining := onUpdateExtra.ReplaceAllString(strings.ToLower(extra), "")
|
||||
for _, known := range []string{"auto_increment", "default_generated", "stored generated", "virtual generated", "invisible"} {
|
||||
remaining = strings.ReplaceAll(remaining, known, "")
|
||||
}
|
||||
if remaining = strings.TrimSpace(remaining); remaining != "" {
|
||||
c.Metadata["mysql_unsupported_extra"] = remaining
|
||||
}
|
||||
if i := strings.Index(strings.ToLower(extra), "on update "); i >= 0 {
|
||||
c.Metadata["mysql_on_update"] = strings.Fields(extra[i+10:])[0]
|
||||
}
|
||||
if srid.Valid {
|
||||
c.Metadata["mysql_srs_id"] = srid.Int64
|
||||
}
|
||||
out[name] = c
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var tableName, create string
|
||||
qualified := "`" + strings.ReplaceAll(schema, "`", "``") + "`.`" + strings.ReplaceAll(table.Name, "`", "``") + "`"
|
||||
if err := r.db.QueryRowContext(r.ctx, "SHOW CREATE TABLE "+qualified).Scan(&tableName, &create); err != nil {
|
||||
return nil, fmt.Errorf("native defaults %s.%s: %w", schema, table.Name, err)
|
||||
}
|
||||
// information_schema AUTO_INCREMENT may be stale because of statistics caching.
|
||||
for _, c := range out {
|
||||
if c.AutoIncrement {
|
||||
table.Metadata["mysql_auto_increment"] = autoIncrementValue(create)
|
||||
break
|
||||
}
|
||||
}
|
||||
if options := unsupportedNativeOptions(create); len(options) > 0 {
|
||||
table.Metadata["mysql_unsupported_options"] = strings.Join(options, ", ")
|
||||
}
|
||||
for name, sql := range columnDefaults(create) {
|
||||
c := out[name]
|
||||
if c == nil || c.Default == nil {
|
||||
continue
|
||||
}
|
||||
if c.Type == "bytea" {
|
||||
c.Default = sql
|
||||
}
|
||||
c.Metadata["mysql_default_sql"] = sql
|
||||
c.Metadata["mysql_default_value"] = fmt.Sprint(c.Default)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func typeSignature(c *models.Column) string {
|
||||
return fmt.Sprintf("%s/%d/%d/%d", c.Type, c.Length, c.Precision, c.Scale)
|
||||
}
|
||||
|
||||
func (r *Reader) queryConstraints(schema, table string) (map[string]*models.Constraint, error) {
|
||||
rows, err := r.db.QueryContext(r.ctx, `SELECT k.CONSTRAINT_NAME, t.CONSTRAINT_TYPE, k.COLUMN_NAME,
|
||||
k.REFERENCED_TABLE_SCHEMA, k.REFERENCED_TABLE_NAME, k.REFERENCED_COLUMN_NAME, k.ORDINAL_POSITION,
|
||||
rc.DELETE_RULE, rc.UPDATE_RULE FROM information_schema.KEY_COLUMN_USAGE k
|
||||
JOIN information_schema.TABLE_CONSTRAINTS t USING (CONSTRAINT_SCHEMA,TABLE_NAME,CONSTRAINT_NAME)
|
||||
LEFT JOIN information_schema.REFERENTIAL_CONSTRAINTS rc ON rc.CONSTRAINT_SCHEMA=k.CONSTRAINT_SCHEMA AND rc.TABLE_NAME=k.TABLE_NAME AND rc.CONSTRAINT_NAME=k.CONSTRAINT_NAME
|
||||
WHERE k.TABLE_SCHEMA=? AND k.TABLE_NAME=? ORDER BY k.CONSTRAINT_NAME,k.ORDINAL_POSITION`, schema, table)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("constraints %s.%s: %w", schema, table, err)
|
||||
}
|
||||
out := map[string]*models.Constraint{}
|
||||
for rows.Next() {
|
||||
var name, typ string
|
||||
var col, rs, rt, rc, del, upd sql.NullString
|
||||
var pos int
|
||||
if err := rows.Scan(&name, &typ, &col, &rs, &rt, &rc, &pos, &del, &upd); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
c := out[name]
|
||||
if c == nil {
|
||||
ct := models.UniqueConstraint
|
||||
if typ == "PRIMARY KEY" {
|
||||
ct = models.PrimaryKeyConstraint
|
||||
}
|
||||
if typ == "FOREIGN KEY" {
|
||||
ct = models.ForeignKeyConstraint
|
||||
}
|
||||
c = models.InitConstraint(name, ct)
|
||||
c.Schema, c.Table = schema, table
|
||||
c.ReferencedSchema, c.ReferencedTable = rs.String, rt.String
|
||||
c.OnDelete, c.OnUpdate = del.String, upd.String
|
||||
out[name] = c
|
||||
}
|
||||
if col.Valid {
|
||||
c.Columns = append(c.Columns, col.String)
|
||||
}
|
||||
if rc.Valid {
|
||||
c.ReferencedColumns = append(c.ReferencedColumns, rc.String)
|
||||
}
|
||||
}
|
||||
err = rows.Err()
|
||||
rows.Close()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows, err = r.db.QueryContext(r.ctx, `SELECT t.CONSTRAINT_NAME,c.CHECK_CLAUSE,t.ENFORCED FROM information_schema.TABLE_CONSTRAINTS t JOIN information_schema.CHECK_CONSTRAINTS c USING (CONSTRAINT_SCHEMA,CONSTRAINT_NAME) WHERE t.TABLE_SCHEMA=? AND t.TABLE_NAME=? AND t.CONSTRAINT_TYPE='CHECK' ORDER BY t.CONSTRAINT_NAME`, schema, table)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("checks %s.%s (requires MySQL 8.0.16+): %w", schema, table, err)
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var name, expr, enforced string
|
||||
if err := rows.Scan(&name, &expr, &enforced); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c := models.InitConstraint(name, models.CheckConstraint)
|
||||
c.Schema, c.Table, c.Expression = schema, table, unescapeExpression(expr)
|
||||
c.Metadata = map[string]any{"mysql_enforced": enforced == "YES"}
|
||||
out[name] = c
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (r *Reader) queryIndexes(schema, table string) (map[string]*models.Index, error) {
|
||||
rows, err := r.db.QueryContext(r.ctx, `SELECT INDEX_NAME, NON_UNIQUE, COLUMN_NAME, SEQ_IN_INDEX, INDEX_TYPE, EXPRESSION, SUB_PART, COLLATION, IS_VISIBLE, INDEX_COMMENT FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=? AND TABLE_NAME=? ORDER BY INDEX_NAME,SEQ_IN_INDEX`, schema, table)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("indexes %s.%s (requires MySQL 8.0.13+): %w", schema, table, err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := map[string]*models.Index{}
|
||||
for rows.Next() {
|
||||
var name, typ, visible, comment string
|
||||
var col, expr, direction sql.NullString
|
||||
var prefix sql.NullInt64
|
||||
var non, seq int
|
||||
if err := rows.Scan(&name, &non, &col, &seq, &typ, &expr, &prefix, &direction, &visible, &comment); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
i := out[name]
|
||||
if i == nil {
|
||||
i = models.InitIndex(name, table, schema)
|
||||
i.Unique = non == 0
|
||||
i.Type = strings.ToLower(typ)
|
||||
i.Comment = comment
|
||||
i.Metadata["mysql_visible"] = visible == "YES"
|
||||
out[name] = i
|
||||
}
|
||||
i.Columns = append(i.Columns, col.String)
|
||||
i.KeyParts = append(i.KeyParts, models.IndexPart{Name: col.String, Expression: unescapeExpression(expr.String), Length: int(prefix.Int64), Descending: direction.String == "D"})
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// Record objects outside the supported table-schema contract rather than silently
|
||||
// discarding them. Writers can warn or reject the conversion before any output.
|
||||
func (r *Reader) readUnsupported(db *models.Database, schema string) error {
|
||||
queries := []struct{ kind, sql string }{
|
||||
{"view", `SELECT TABLE_NAME FROM information_schema.VIEWS WHERE TABLE_SCHEMA=?`},
|
||||
{"trigger", `SELECT TRIGGER_NAME FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA=?`},
|
||||
{"routine", `SELECT ROUTINE_NAME FROM information_schema.ROUTINES WHERE ROUTINE_SCHEMA=?`},
|
||||
{"event", `SELECT EVENT_NAME FROM information_schema.EVENTS WHERE EVENT_SCHEMA=?`},
|
||||
{"partitioned table", `SELECT DISTINCT TABLE_NAME FROM information_schema.PARTITIONS WHERE TABLE_SCHEMA=? AND PARTITION_NAME IS NOT NULL`},
|
||||
}
|
||||
var unsupported []string
|
||||
for _, q := range queries {
|
||||
rows, err := r.db.QueryContext(r.ctx, q.sql, schema)
|
||||
if err != nil {
|
||||
return fmt.Errorf("discover %s: %w", q.kind, err)
|
||||
}
|
||||
for rows.Next() {
|
||||
var name string
|
||||
if err := rows.Scan(&name); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
unsupported = append(unsupported, q.kind+" "+schema+"."+name)
|
||||
}
|
||||
err = rows.Err()
|
||||
rows.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if len(unsupported) > 0 {
|
||||
db.Metadata["mysql_unsupported"] = unsupported
|
||||
for _, s := range db.Schemas {
|
||||
s.Metadata["mysql_unsupported"] = unsupported
|
||||
}
|
||||
if r.options.Progress != nil {
|
||||
for _, item := range unsupported {
|
||||
r.options.Progress("warning: unsupported MySQL " + item)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Reader) deriveRelationship(t *models.Table, c *models.Constraint) {
|
||||
n := fmt.Sprintf("%s_%s", t.Name, c.Name)
|
||||
rel := models.InitRelationship(n, models.OneToMany)
|
||||
rel.FromTable, rel.FromSchema = t.Name, t.Schema
|
||||
rel.FromColumns = append([]string(nil), c.Columns...)
|
||||
rel.ToTable, rel.ToSchema = c.ReferencedTable, c.ReferencedSchema
|
||||
rel.ToColumns = append([]string(nil), c.ReferencedColumns...)
|
||||
rel.ForeignKey = c.Name
|
||||
t.Relationships[n] = rel
|
||||
}
|
||||
|
||||
// MySQL information_schema escapes SQL expressions by an additional layer
|
||||
// (including quote delimiters). This covers defaults, generated columns, checks,
|
||||
// and functional indexes. Literal defaults are already values and bypass this.
|
||||
func unescapeExpression(s string) string {
|
||||
var out strings.Builder
|
||||
for i := 0; i < len(s); i++ {
|
||||
if s[i] == '\\' && i+1 < len(s) {
|
||||
i++
|
||||
switch s[i] {
|
||||
case 'n':
|
||||
out.WriteByte('\n')
|
||||
case 'r':
|
||||
out.WriteByte('\r')
|
||||
case 't':
|
||||
out.WriteByte('\t')
|
||||
case '0':
|
||||
out.WriteByte(0)
|
||||
case 'Z':
|
||||
out.WriteByte(26)
|
||||
default:
|
||||
out.WriteByte(s[i])
|
||||
}
|
||||
} else {
|
||||
out.WriteByte(s[i])
|
||||
}
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers"
|
||||
)
|
||||
|
||||
func TestReaderMapDataType(t *testing.T) {
|
||||
r := NewReader(&readers.ReaderOptions{})
|
||||
for _, tc := range []struct{ input, want string }{{"varchar(64)", "string"}, {"bigint unsigned", "uint64"}, {"datetime", "timestamp"}, {"json", "json"}} {
|
||||
if got := r.mapDataType(tc.input); got != tc.want {
|
||||
t.Errorf("mapDataType(%q) = %q, want %q", tc.input, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReaderRequiresConnectionString(t *testing.T) {
|
||||
if _, err := NewReader(&readers.ReaderOptions{}).ReadDatabase(); err == nil {
|
||||
t.Fatal("expected missing connection string error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRelationshipNamesCannotCollapseDistinctForeignKeys(t *testing.T) {
|
||||
table := models.InitTable("t", "app")
|
||||
reader := NewReader(&readers.ReaderOptions{})
|
||||
for _, c := range []*models.Constraint{
|
||||
{Name: "a_to_b", ReferencedTable: "c"},
|
||||
{Name: "a", ReferencedTable: "b_to_c"},
|
||||
} {
|
||||
reader.deriveRelationship(table, c)
|
||||
}
|
||||
if len(table.Relationships) != 2 {
|
||||
t.Fatalf("distinct foreign keys collapsed: %+v", table.Relationships)
|
||||
}
|
||||
}
|
||||
@@ -422,31 +422,37 @@ func (r *Reader) queryPrimaryKeys(schemaName string) (map[string]*models.Constra
|
||||
// queryForeignKeys retrieves all foreign key constraints for a schema
|
||||
// Returns map[schema.table][]*Constraint
|
||||
func (r *Reader) queryForeignKeys(schemaName string) (map[string][]*models.Constraint, error) {
|
||||
// Columns are paired by position from pg_constraint so composite keys are not
|
||||
// cross-joined (information_schema.constraint_column_usage has no ordering).
|
||||
actionCase := func(col string) string {
|
||||
return `CASE ` + col + ` WHEN 'a' THEN 'NO ACTION' WHEN 'r' THEN 'RESTRICT' WHEN 'c' THEN 'CASCADE' WHEN 'n' THEN 'SET NULL' WHEN 'd' THEN 'SET DEFAULT' END`
|
||||
}
|
||||
query := `
|
||||
SELECT
|
||||
tc.table_schema,
|
||||
tc.table_name,
|
||||
tc.constraint_name,
|
||||
kcu.table_schema as foreign_table_schema,
|
||||
kcu.table_name as foreign_table_name,
|
||||
kcu.column_name as foreign_column,
|
||||
ccu.table_schema as referenced_table_schema,
|
||||
ccu.table_name as referenced_table_name,
|
||||
ccu.column_name as referenced_column,
|
||||
rc.update_rule,
|
||||
rc.delete_rule
|
||||
FROM information_schema.table_constraints tc
|
||||
JOIN information_schema.key_column_usage kcu
|
||||
ON tc.constraint_name = kcu.constraint_name
|
||||
AND tc.table_schema = kcu.table_schema
|
||||
JOIN information_schema.constraint_column_usage ccu
|
||||
ON ccu.constraint_name = tc.constraint_name
|
||||
JOIN information_schema.referential_constraints rc
|
||||
ON rc.constraint_name = tc.constraint_name
|
||||
AND rc.constraint_schema = tc.table_schema
|
||||
WHERE tc.constraint_type = 'FOREIGN KEY'
|
||||
AND tc.table_schema = $1
|
||||
ORDER BY tc.table_schema, tc.table_name, tc.constraint_name, kcu.ordinal_position
|
||||
ns.nspname AS table_schema,
|
||||
cl.relname AS table_name,
|
||||
con.conname AS constraint_name,
|
||||
ns.nspname AS foreign_table_schema,
|
||||
cl.relname AS foreign_table_name,
|
||||
att.attname AS foreign_column,
|
||||
fns.nspname AS referenced_table_schema,
|
||||
fcl.relname AS referenced_table_name,
|
||||
fatt.attname AS referenced_column,
|
||||
` + actionCase("con.confupdtype") + ` AS update_rule,
|
||||
` + actionCase("con.confdeltype") + ` AS delete_rule,
|
||||
con.condeferrable AS deferrable,
|
||||
con.condeferred AS initially_deferred
|
||||
FROM pg_catalog.pg_constraint con
|
||||
JOIN pg_catalog.pg_class cl ON cl.oid = con.conrelid
|
||||
JOIN pg_catalog.pg_namespace ns ON ns.oid = cl.relnamespace
|
||||
JOIN pg_catalog.pg_class fcl ON fcl.oid = con.confrelid
|
||||
JOIN pg_catalog.pg_namespace fns ON fns.oid = fcl.relnamespace
|
||||
CROSS JOIN LATERAL unnest(con.conkey, con.confkey) WITH ORDINALITY AS k(attnum, fattnum, ord)
|
||||
JOIN pg_catalog.pg_attribute att ON att.attrelid = con.conrelid AND att.attnum = k.attnum
|
||||
JOIN pg_catalog.pg_attribute fatt ON fatt.attrelid = con.confrelid AND fatt.attnum = k.fattnum
|
||||
WHERE con.contype = 'f'
|
||||
AND ns.nspname = $1
|
||||
ORDER BY ns.nspname, cl.relname, con.conname, k.ord
|
||||
`
|
||||
|
||||
rows, err := r.conn.Query(r.ctx, query, schemaName)
|
||||
@@ -466,6 +472,8 @@ func (r *Reader) queryForeignKeys(schemaName string) (map[string][]*models.Const
|
||||
referencedColumns []string
|
||||
updateRule string
|
||||
deleteRule string
|
||||
deferrable bool
|
||||
initiallyDeferred bool
|
||||
}
|
||||
|
||||
fkMap := make(map[string]*fkData)
|
||||
@@ -475,8 +483,9 @@ func (r *Reader) queryForeignKeys(schemaName string) (map[string][]*models.Const
|
||||
var foreignSchema, foreignTable, foreignColumn string
|
||||
var referencedSchema, referencedTable, referencedColumn string
|
||||
var updateRule, deleteRule string
|
||||
var deferrable, initiallyDeferred bool
|
||||
|
||||
if err := rows.Scan(&schema, &tableName, &constraintName, &foreignSchema, &foreignTable, &foreignColumn, &referencedSchema, &referencedTable, &referencedColumn, &updateRule, &deleteRule); err != nil {
|
||||
if err := rows.Scan(&schema, &tableName, &constraintName, &foreignSchema, &foreignTable, &foreignColumn, &referencedSchema, &referencedTable, &referencedColumn, &updateRule, &deleteRule, &deferrable, &initiallyDeferred); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -493,6 +502,8 @@ func (r *Reader) queryForeignKeys(schemaName string) (map[string][]*models.Const
|
||||
referencedColumns: []string{},
|
||||
updateRule: updateRule,
|
||||
deleteRule: deleteRule,
|
||||
deferrable: deferrable,
|
||||
initiallyDeferred: initiallyDeferred,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -513,6 +524,9 @@ func (r *Reader) queryForeignKeys(schemaName string) (map[string][]*models.Const
|
||||
constraint.ReferencedColumns = fk.referencedColumns
|
||||
constraint.OnUpdate = fk.updateRule
|
||||
constraint.OnDelete = fk.deleteRule
|
||||
deferrable, initiallyDeferred := fk.deferrable, fk.initiallyDeferred
|
||||
constraint.Deferrable = &deferrable
|
||||
constraint.InitiallyDeferred = &initiallyDeferred
|
||||
|
||||
tableKey := fk.schema + "." + fk.tableName
|
||||
foreignKeys[tableKey] = append(foreignKeys[tableKey], constraint)
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
package pgsql
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||
)
|
||||
|
||||
func TestNormalizePostgresDefault(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{"empty", "", ""},
|
||||
{"function", "now()", "now()"},
|
||||
{"nextval passthrough", "nextval('seq'::regclass)", "nextval('seq'::regclass)"},
|
||||
{"number", "42", "42"},
|
||||
{"null cast", "NULL::text", "NULL::text"},
|
||||
{"quoted literal", "'abc'", "abc"},
|
||||
{"quoted with cast", "'abc'::character varying", "abc"},
|
||||
{"escaped quote", "'it''s'::text", "it's"},
|
||||
{"empty literal", "''::text", ""},
|
||||
{"only escaped quotes", "''''", "'"},
|
||||
{"unterminated", "'abc", "abc"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := normalizePostgresDefault(tt.in); got != tt.want {
|
||||
t.Errorf("normalizePostgresDefault(%q) = %q, want %q", tt.in, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCountHelpers(t *testing.T) {
|
||||
cols := map[string]map[string]*models.Column{
|
||||
"a": {"x": {}, "y": {}},
|
||||
"b": {"z": {}},
|
||||
"c": {},
|
||||
}
|
||||
if got := countColumns(cols); got != 3 {
|
||||
t.Errorf("countColumns = %d, want 3", got)
|
||||
}
|
||||
if got := countColumns(nil); got != 0 {
|
||||
t.Errorf("countColumns(nil) = %d, want 0", got)
|
||||
}
|
||||
|
||||
cons := map[string][]*models.Constraint{"a": {{}, {}}, "b": {{}}}
|
||||
if got := countConstraints(cons); got != 3 {
|
||||
t.Errorf("countConstraints = %d, want 3", got)
|
||||
}
|
||||
if got := countConstraints(nil); got != 0 {
|
||||
t.Errorf("countConstraints(nil) = %d, want 0", got)
|
||||
}
|
||||
|
||||
idx := map[string][]*models.Index{"a": {{}}, "b": {{}, {}, {}}}
|
||||
if got := countIndexes(idx); got != 4 {
|
||||
t.Errorf("countIndexes = %d, want 4", got)
|
||||
}
|
||||
if got := countIndexes(nil); got != 0 {
|
||||
t.Errorf("countIndexes(nil) = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractIndexOperatorClass(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
in []string
|
||||
want string
|
||||
}{
|
||||
{"none", nil, ""},
|
||||
{"sort modifiers only", []string{"DESC", "NULLS", "LAST"}, ""},
|
||||
{"opclass", []string{"", " Vector_Cosine_Ops "}, "vector_cosine_ops"},
|
||||
{"opclass after ordering", []string{"desc", "gin_trgm_ops"}, "gin_trgm_ops"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := extractIndexOperatorClass(tt.in); got != tt.want {
|
||||
t.Errorf("got %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildIndexHint(t *testing.T) {
|
||||
tests := []struct {
|
||||
opClass, params, want string
|
||||
}{
|
||||
{"", "", ""},
|
||||
{"vector_cosine_ops", "", "opclass=vector_cosine_ops"},
|
||||
{"", "m=16", "with (m=16)"},
|
||||
{"vector_cosine_ops", "m=16", "opclass=vector_cosine_ops; with (m=16)"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := buildIndexHint(tt.opClass, tt.params); got != tt.want {
|
||||
t.Errorf("buildIndexHint(%q,%q) = %q, want %q", tt.opClass, tt.params, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeIndexStorageParams(t *testing.T) {
|
||||
tests := []struct{ in, want string }{
|
||||
{"", ""},
|
||||
{"m='16', ef_construction='64'", "m=16, ef_construction=64"},
|
||||
{"key_field='id'", "key_field='id'"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := normalizeIndexStorageParams(tt.in); got != tt.want {
|
||||
t.Errorf("normalizeIndexStorageParams(%q) = %q, want %q", tt.in, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,14 @@
|
||||
package pgsql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers"
|
||||
)
|
||||
@@ -499,3 +503,60 @@ func TestMapDataType_ExtensionTypesPreserveModifiers(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReader_CompositeForeignKeyColumnsArePairedOnce(t *testing.T) {
|
||||
connStr := getTestConnectionString(t)
|
||||
|
||||
ctx := context.Background()
|
||||
conn, err := pgx.Connect(ctx, connStr)
|
||||
if err != nil {
|
||||
t.Fatalf("connect: %v", err)
|
||||
}
|
||||
defer conn.Close(ctx)
|
||||
|
||||
const schema = "relspec_fk_test"
|
||||
setup := []string{
|
||||
"DROP SCHEMA IF EXISTS " + schema + " CASCADE",
|
||||
"CREATE SCHEMA " + schema,
|
||||
"CREATE TABLE " + schema + ".parent (a int, b int, PRIMARY KEY (a, b))",
|
||||
"CREATE TABLE " + schema + ".child (x int, y int, CONSTRAINT fk_child_parent FOREIGN KEY (x, y) REFERENCES " + schema + ".parent (a, b) ON DELETE CASCADE)",
|
||||
}
|
||||
for _, stmt := range setup {
|
||||
if _, err := conn.Exec(ctx, stmt); err != nil {
|
||||
t.Fatalf("setup %q: %v", stmt, err)
|
||||
}
|
||||
}
|
||||
defer conn.Exec(ctx, "DROP SCHEMA IF EXISTS "+schema+" CASCADE")
|
||||
|
||||
reader := NewReader(&readers.ReaderOptions{ConnectionString: connStr})
|
||||
db, err := reader.ReadDatabase()
|
||||
if err != nil {
|
||||
t.Fatalf("ReadDatabase: %v", err)
|
||||
}
|
||||
|
||||
for _, s := range db.Schemas {
|
||||
if s.Name != schema {
|
||||
continue
|
||||
}
|
||||
for _, tbl := range s.Tables {
|
||||
if tbl.Name != "child" {
|
||||
continue
|
||||
}
|
||||
fk := tbl.Constraints["fk_child_parent"]
|
||||
if fk == nil {
|
||||
t.Fatal("foreign key fk_child_parent not read")
|
||||
}
|
||||
if got := strings.Join(fk.Columns, ","); got != "x,y" {
|
||||
t.Errorf("columns = %q, want x,y", got)
|
||||
}
|
||||
if got := strings.Join(fk.ReferencedColumns, ","); got != "a,b" {
|
||||
t.Errorf("referenced columns = %q, want a,b", got)
|
||||
}
|
||||
if fk.OnDelete != "CASCADE" || fk.OnUpdate != "NO ACTION" {
|
||||
t.Errorf("rules = %s/%s, want CASCADE/NO ACTION", fk.OnDelete, fk.OnUpdate)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatal("test schema/table not found in read result")
|
||||
}
|
||||
|
||||
@@ -13,7 +13,8 @@ import (
|
||||
|
||||
// Reader implements the readers.Reader interface for Prisma schema format
|
||||
type Reader struct {
|
||||
options *readers.ReaderOptions
|
||||
options *readers.ReaderOptions
|
||||
enumNames map[string]bool // enum names declared in the schema being parsed
|
||||
}
|
||||
|
||||
// NewReader creates a new Prisma reader with the given options
|
||||
@@ -82,6 +83,8 @@ func (r *Reader) parsePrisma(content string) (*models.Database, error) {
|
||||
schema := models.InitSchema("public")
|
||||
schema.Enums = make([]*models.Enum, 0)
|
||||
|
||||
r.enumNames = collectEnumNames(content)
|
||||
|
||||
scanner := bufio.NewScanner(strings.NewReader(content))
|
||||
|
||||
// State tracking
|
||||
@@ -600,20 +603,21 @@ func (r *Reader) isPrimitiveType(typeName string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// isEnumType checks if a type name might be an enum
|
||||
// Note: We can't definitively check against schema.Enums at parse time
|
||||
// because enums might be defined after the model, so we just check
|
||||
// if it starts with uppercase (Prisma convention for enums)
|
||||
func (r *Reader) isEnumType(typeName string, table *models.Table) bool {
|
||||
// Simple heuristic: enum types start with uppercase letter
|
||||
// and are not known model names (though we can't check that yet)
|
||||
if len(typeName) > 0 && typeName[0] >= 'A' && typeName[0] <= 'Z' {
|
||||
// Additional check: primitive types are already handled above
|
||||
// So if it's uppercase and not primitive, it's likely an enum or model
|
||||
// We'll assume it's an enum if it's a single word
|
||||
return !strings.Contains(typeName, "_")
|
||||
// isEnumType reports whether typeName is an enum declared in the schema.
|
||||
// Enum names are collected up front because enums may be declared after the
|
||||
// models that use them.
|
||||
func (r *Reader) isEnumType(typeName string, _ *models.Table) bool {
|
||||
return r.enumNames[typeName]
|
||||
}
|
||||
|
||||
var enumDeclRegex = regexp.MustCompile(`(?m)^\s*enum\s+(\w+)\s*{`)
|
||||
|
||||
func collectEnumNames(content string) map[string]bool {
|
||||
names := make(map[string]bool)
|
||||
for _, m := range enumDeclRegex.FindAllStringSubmatch(content, -1) {
|
||||
names[m[1]] = true
|
||||
}
|
||||
return false
|
||||
return names
|
||||
}
|
||||
|
||||
// createConstraintFromRelation creates a FK constraint from a @relation attribute
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user