Compare commits

..
80 Commits
Author SHA1 Message Date
warkanum 32cf25ff73 chore(release): update package version to 1.0.91
Release / test (push) Successful in 54s
Release / release (push) Successful in 2m31s
Release / AUR package (relspec) (push) Successful in 50s
Release / pkg-rpm (push) Successful in 1m50s
Release / AUR package (relspec-bin) (push) Successful in 52s
Release / pkg-deb (push) Successful in 1m32s
2026-10-12 22:15:53 +02:00
warkanum 032093e8c0 feat(packaging): add relspec-bin AUR package
- Add linux/arch/relspec-bin/PKGBUILD using prebuilt release tarballs
- Move source PKGBUILD to linux/arch/relspec/PKGBUILD
- Release: build static binaries, pack linux tarballs (binary + LICENSE)
- AUR job: matrix over relspec and relspec-bin
- Makefile: bump pkgver in both PKGBUILDs
2026-10-12 22:15:07 +02:00
warkanum 69a27784d1 chore(release): update package version to 1.0.90
Release / test (push) Successful in 54s
Release / release (push) Successful in 3m12s
Release / pkg-aur (push) Successful in 52s
Release / pkg-deb (push) Successful in 1m22s
Release / pkg-rpm (push) Successful in 1m55s
2026-10-12 22:06:59 +02:00
warkanum 1d93255575 fix(assets): improve error reporting for asset failures
fix(embed): update embedDirectives to return result directly

fix(executor): clean up import order and formatting

fix(syntax_test): change file permission syntax to octal

fix(mysql): update file permission syntax to octal

fix(pgsql): update file permission syntax to octal

fix(sqlex): simplify Walk function parameter handling

fix(transform): adjust identifier comparison logic

fix(template): clean up function formatting and error handling
2026-10-12 22:04:40 +02:00
warkanum 05466a92b1 fix: preserve MySQL schema fidelity 2026-10-12 21:56:48 +02:00
warkanum dd97413b8e fix: contain generated files and assets and harden SQL rendering 2026-10-12 20:59:46 +02:00
warkanum 2fa48335d3 fix: address audit execution failures and unsafe type conversions 2026-10-12 19:53:19 +02:00
Hein bfe9852826 chore(Makefile): update govulncheck command to use pinned toolchain 2026-10-12 09:49:25 +02:00
Hein e3bc6cf491 chore(go): require Go 1.26.9 for stdlib vulnerability fixes
Release / test (push) Successful in 2m10s
Release / release (push) Successful in 2m48s
Release / pkg-aur (push) Successful in 33m34s
Release / pkg-deb (push) Successful in 1m35s
Release / pkg-rpm (push) Successful in 2m0s
go 1.26.0 made CI's setup-go install a toolchain with 27 known stdlib
vulnerabilities (fixed in 1.26.1-1.26.9).
2026-10-12 09:48:05 +02:00
Hein fa6a5d0339 chore(release): update package version to 1.0.89
Release / test (push) Failing after 2m37s
Release / release (push) Skipped
Release / pkg-aur (push) Skipped
Release / pkg-deb (push) Skipped
Release / pkg-rpm (push) Skipped
2026-10-12 09:33:34 +02:00
Hein 337af294f3 chore(deps): update all dependencies
Fixes GO-2026-6629 (golang.org/x/text precis panic). Bumps bun and
bun/dialect/pgdialect together to v1.3.0; go directive now 1.26.0.
2026-10-12 09:31:55 +02:00
Hein a4c68e5661 ci(release): attach source archives to release and use them for AUR package
Gitea's /archive/ endpoint is disabled, which broke the AUR source download.
Build source tar.gz/zip with git archive, upload as release assets, and point
the PKGBUILD and checksum step at the release asset.
2026-10-12 09:28:52 +02:00
warkanum 9f0269f591 chore(release): update package version to 1.0.88
Release / test (push) Failing after 1m36s
Release / release (push) Skipped
Release / pkg-aur (push) Skipped
Release / pkg-deb (push) Skipped
Release / pkg-rpm (push) Skipped
2026-10-08 00:08:26 +02:00
warkanum b4f18f088b feat(pgsql): unique constraints on apply, matching unique index names, FK deferral
- migration writer now emits unique constraints (direct apply and live diff
  previously dropped them); idempotent via the existing DO block
- unique constraint adopts a same-named model unique index with
  ADD CONSTRAINT ... UNIQUE USING INDEX instead of building a second index
- dbml reader names unnamed unique indexes ukey_<table>_<cols> to match the
  unique constraint naming
- foreign key deferral: Constraint.Deferrable/InitiallyDeferred are *bool
  (nil = unspecified, never diffed); writers emit DEFERRABLE, DEFERRABLE
  INITIALLY DEFERRED and NOT DEFERRABLE; live reader reads condeferrable and
  condeferred; dbml reader/writer support deferrable and initially deferred
- tests and README/naming docs updated
2026-10-08 00:07:49 +02:00
Hein 20f8dd82b6 fix(models): ensure unique GUIDs in InitTable function
Release / test (push) Successful in 2m47s
Release / release (push) Successful in 2m31s
Release / pkg-aur (push) Successful in 1m0s
Release / pkg-deb (push) Successful in 2m7s
Release / pkg-rpm (push) Successful in 2m8s
2026-10-05 17:29:23 +02:00
Hein d26d720e93 chore(release): update package version to 1.0.87
Release / release (push) Canceled after 0s
Release / pkg-aur (push) Canceled after 0s
Release / pkg-deb (push) Canceled after 0s
Release / pkg-rpm (push) Canceled after 0s
Release / test (push) Canceled after 2m42s
2026-10-05 17:27:11 +02:00
warkanum 2796ebe381 chore(release): update package version to 1.0.86
Release / test (push) Failing after 2m29s
Release / release (push) Skipped
Release / pkg-aur (push) Skipped
Release / pkg-deb (push) Skipped
Release / pkg-rpm (push) Skipped
2026-10-03 23:23:20 +02:00
warkanum 9447d18323 docs: README covers mysql, update command and Windows installer 2026-10-03 23:22:03 +02:00
warkanum 9da69f4234 docs(tests): update coverage plans status 2026-10-03 23:21:26 +02:00
warkanum 2c304dc363 Merge pull request 'feat: NSIS Windows installer and update check (#32)' (#56) from issue-32-nsis-installer into master
Reviewed-on: #56
2026-10-03 21:19:58 +00:00
warkanum 02a394c99d feat: NSIS Windows installer and update check (#32)
- windows/installer.nsi: installs to Program Files, manages system PATH,
  registers uninstaller
- relspec update [--check] [--yes]: compares against the latest release and
  prompts; on Windows downloads and runs the installer
- release workflow builds and uploads relspec-setup-windows-amd64.exe
- make installer-windows
2026-10-03 21:44:08 +02:00
warkanum b49859537b style: reformat test tables 2026-10-03 21:41:54 +02:00
warkanum bed80b046b Merge pull request 'Fix/writers readers determinism and tests' (#55) from fix/writers-readers-determinism-and-tests into master
Reviewed-on: #55
2026-10-03 19:34:59 +00:00
warkanum 495a21b67b test: expand coverage across readers, writers, cmd, ui, diff and merge
Implements tests/_plans and previously deferred packages; updates plan
README with new coverage numbers.
2026-10-03 21:33:59 +02:00
warkanum a32647ee16 fix: writer/reader correctness and determinism issues
- merge: cloneTable keeps relationships; skip-tables applies to new schemas
- diff: detect schema description/owner changes
- prisma: reader no longer turns relation fields into columns, enum
  detection via declared names; writer type mapping is ordered
- typeorm writer: keep explicit SQL types that cannot be inferred
- drizzle: enum columns call the enum constant; reader resolves them
- mysql/mssql/sqlite writers: honour OutputPath, deterministic column
  and constraint order; mssql live execute covers full schema
- template: ToYAML recovers from panics; Merge nil-pointer loop
- regenerate drizzle fixtures
2026-10-03 21:33:59 +02:00
warkanum 08e1417393 Merge pull request 'fix(ui): resolve lint issues in file browser' (#54) from fix/lint-filebrowser into master
Reviewed-on: #54
2026-10-03 18:17:30 +00:00
warkanum 70282fff73 fix(mysql): resolve lint issues in reader and writer 2026-10-03 20:17:16 +02:00
warkanum 43265dac0f fix(ui): resolve lint issues in file browser 2026-10-03 20:16:20 +02:00
warkanum 66b90ca54b Merge pull request 'feat(cli): add batch command for converting multiple inputs (#38)' (#49) from issue-38-batch-processing into master
Reviewed-on: #49
2026-10-03 18:14:30 +00:00
warkanum 47108809aa Merge remote-tracking branch 'origin/master' into issue-38-batch-processing
# Conflicts:
#	README.md
2026-10-03 20:13:22 +02:00
warkanum 720476fd6e Merge pull request 'docs(ui): plan TUI mouse support' (#53) from issue-46-mouse-support-plan into master
Reviewed-on: #53
2026-10-03 18:12:04 +00:00
warkanum 572d03fe42 Merge pull request 'feat: add MySQL reader and writer' (#52) from issue-34-mysql-driver into master
Reviewed-on: #52
2026-10-03 18:11:50 +00:00
warkanum f1b9079b2d Merge pull request 'test(sqltypes): cover scalar conversions and constructors' (#51) from issue-45-test-coverage into master
Reviewed-on: #51
2026-10-03 18:11:40 +00:00
warkanum bb671c3680 Merge pull request 'feat(ui): file browser and connection string builder dialogs' (#50) from issue-44-tui-input-dialogs into master
Reviewed-on: #50
2026-10-03 18:11:28 +00:00
warkanum bc8284db25 Merge pull request 'feat(cli): watch mode for convert (#39)' (#48) from issue-39-watch-mode into master
Reviewed-on: #48
2026-10-03 18:11:10 +00:00
warkanum 29e747393d Merge pull request 'feat: custom SQL-to-Go type mapping (--type-map) for bun/gorm' (#47) from issue-36-type-mapping into master
Reviewed-on: #47
2026-10-03 18:10:59 +00:00
SG Command df980a3434 docs(ui): plan TUI mouse support 2026-10-03 13:24:46 +02:00
SG Command 778379538b fix: make MySQL writer execute generated DDL 2026-10-03 12:42:07 +02:00
SG Command 7a9219b6e3 feat: add MySQL reader and writer 2026-10-03 12:36:39 +02:00
SG Command fd9c37cd25 test(sqltypes): cover scalar conversions and constructors 2026-10-03 12:30:26 +02:00
SG CommandandClaude Sonnet 5.5 0235a28add feat(ui): file browser and connection string builder dialogs (#44)
Enter on File Path inputs opens a file browser (load/save, extension
filter, hidden toggle, overwrite confirm). Enter on Connection String
inputs opens a builder for PostgreSQL, MSSQL and SQLite with masked
password/preview, parsing and optional connection test.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 11:35:12 +02:00
SG CommandandClaude Sonnet 5.5 d961536186 feat(cli): add batch command to convert many inputs in one run
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 11:28:57 +02:00
Hermes AgentandClaude Sonnet 5.5 d36806047b feat(cli): add --watch mode to convert (#39)
Poll source files/directories and regenerate output on change.
Output path is excluded from watching; errors don't stop the loop.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 10:29:42 +02:00
Hermes AgentandClaude Sonnet 5.5 948419ffd3 feat: add --type-map to override SQL-to-Go types in bun and gorm writers
Adds WriterOptions.TypeMappings and a repeatable --type-map sqltype=gotype
flag. Defaults are unchanged when no mapping is given. Closes #36 (bun/gorm).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 10:28:58 +02:00
warkanum b38f53c603 docs(tests): add test coverage plans 2026-10-03 10:01:48 +02:00
warkanum ccba53c494 test: add podman/docker dbtest tool for postgres, mssql and mysql 2026-10-03 10:01:48 +02:00
sgcommand 53327b9a5a feat(ui): TUI indexes, views, sequences, scripts, domain/table assignment (#40) (#43)
Co-authored-by: SG Command <sgcommand@warky.dev>
2026-10-03 07:27:25 +00:00
sgcommand 734b14d48d docs: add usage examples for each format combination (#42)
Co-authored-by: SG Command <sgcommand@warky.dev>
2026-10-03 07:27:03 +00:00
sgcommand 938f0ed51f feat(cli): add --dry-run to convert, merge and split (#41)
Co-authored-by: SG Command <sgcommand@warky.dev>
2026-10-03 07:26:54 +00:00
warkanum 6e2e7eb19e feat(release): add rerelease target to move latest tag 2026-10-02 23:41:42 +02:00
warkanum 82e86cdf39 fix(release): run lint and format check before version release
Release / test (push) Successful in 2m7s
Release / release (push) Successful in 1m49s
Release / pkg-aur (push) Successful in 50s
Release / pkg-deb (push) Successful in 1m30s
Release / pkg-rpm (push) Successful in 1m48s
2026-10-02 23:38:28 +02:00
warkanum b7ae00ab52 fix(test): simplify parameter list in identity column builder 2026-10-02 23:35:56 +02:00
warkanum 8f8664357e chore(release): update package version to 1.0.85
Release / test (push) Failing after 2m1s
Release / release (push) Skipped
Release / pkg-aur (push) Skipped
Release / pkg-deb (push) Skipped
Release / pkg-rpm (push) Skipped
2026-10-02 23:32:07 +02:00
warkanum 7bd61a21b5 fix: resolve gofumpt and staticcheck lint errors 2026-10-02 23:31:41 +02:00
warkanum 6f6b9834ca docs(CONTRIBUTING): update setup instructions and add make targets 2026-10-02 23:29:25 +02:00
warkanum 9cc10715e3 feat: generated and identity columns for mssql, sqlite, drizzle, typeorm, bun and gorm readers
- mssql: write computed columns (AS (expr) PERSISTED) and identity; read computed definition and identity
- sqlite: write GENERATED ALWAYS AS (expr) STORED; read generated columns via table_xinfo and parse the expression
- drizzle: generatedAlwaysAs / generated(Always|ByDefault)AsIdentity, read and write
- typeorm: asExpression/generatedType and identity decorators, read and write; decorator scan is now quote-aware
- bun, gorm readers: read generated and identity markers
- readmes updated
2026-10-02 23:04:04 +02:00
warkanum bbab5ce936 feat: mark generated and identity columns across writers and DBML
- bun: scanonly+generated for GENERATED columns; scanonly+identity for ALWAYS identity (PK gets identity only)
- gorm: <-:false+generated / identity, same primary key rule
- dbml: write GENERATED/IDENTITY column notes and read them back into the model
- pgsql: emit generation/identity clauses in migration create-table and add-column, no DEFAULT
- readmes updated
2026-10-02 22:58:00 +02:00
warkanum 275424c605 feat(pgsql): diff against the live database by default for direct output
Direct pgsql output (job output.conn_env, merge --output-conn) now reads the live
schema and executes only the differences. full_ddl: true (job option) or --full-ddl
(merge) restores the full idempotent DDL; file output is unchanged.

- compare PKs by columns, skip constraint-backed indexes, normalize index method,
  FK actions, serial/numeric types, default literals/casts and truncated names
- diff table and column comments instead of re-emitting them
- remove leftover ZZDUMP debug code
2026-10-02 22:52:59 +02:00
warkanum b6c0cd3d1b fix(pgsql-reader): pair composite foreign key columns by position
key_column_usage and constraint_column_usage were joined on constraint name only,
so an N-column foreign key returned N*N column pairs. Read from pg_constraint with
unnest(conkey, confkey) instead.
2026-10-02 22:52:53 +02:00
warkanum d7d1d99ebc fix(pgsql): tie PK sequence to nextval default, setval past data, keep serial defaults
- derive the primary key sequence from the column's nextval() default instead of an unused identity_<table>_<pk> sequence
- setval after table creation (full and diff paths), forward-only, MAX+1 with is_called=false
- do not DROP DEFAULT on serial/bigserial columns without a model default
2026-10-02 22:37:37 +02:00
warkanum 43849324ce chore(release): update package version to 1.0.84
Release / test (push) Successful in 1m25s
Release / release (push) Successful in 2m7s
Release / pkg-aur (push) Successful in 55s
Release / pkg-deb (push) Successful in 1m42s
Release / pkg-rpm (push) Successful in 1m49s
2026-09-24 15:41:46 +02:00
warkanum e1df3b7cee fix(writers): drop version stamps from generated output
Generated files carried the RelSpec version and build date, so every
rebuild produced a diff even when the schema was unchanged.

- bun, gorm, drizzle: remove the GeneratedBy template field and line
- pgsql, mssql, sqlite: emit a constant "-- Generated by RelSpec"
- bun: replace the version header test with a version-free assertion

pkg/buildinfo is unchanged and still backs the CLI version.
2026-09-24 15:40:37 +02:00
warkanum f7b5d5f054 chore(release): update package version to 1.0.83
Release / pkg-deb (push) Successful in 1m47s
Release / test (push) Successful in 59s
Release / release (push) Successful in 2m0s
Release / pkg-aur (push) Successful in 54s
Release / pkg-rpm (push) Successful in 1m56s
2026-09-23 18:52:48 +02:00
warkanum 99fc4b0944 Merge pull request 'Fix/dbml commented refs duplicate indexes' (#31) from fix/dbml-commented-refs-duplicate-indexes into master
Reviewed-on: #31
2026-09-23 16:48:54 +00:00
warkanum 278d488363 chore(lint): use strings.EqualFold in jobs output format check 2026-09-23 18:48:07 +02:00
warkanum 2f69205aa0 fix(dbml): resolve commented cross-file // Ref: lines
Commented refs are collected per file and resolved against the combined
model after all inputs are loaded (directory, --from-list, merge, jobs).
Matched refs become FKs and relationships; duplicates of existing FKs are
skipped; missing targets are skipped with a warning; column type
mismatches warn.

Also keep reused index names within a DBML table instead of overwriting,
give a second FK to the same table a distinct relationship name, and make
the pgsql writer match relationships to FKs by name first.
2026-09-23 18:48:07 +02:00
warkanum b91985c493 feat(inspector): fail on duplicate index names per schema
PostgreSQL index names share the schema-wide relation namespace, so a
reused name makes CREATE INDEX IF NOT EXISTS silently skip the duplicate.
Add duplicate_index_name rule (enforce by default) covering indexes and
PK/unique constraint backing indexes.
2026-09-23 18:48:07 +02:00
warkanum 80a3453233 Merge pull request 'fix(jobs): preserve writer and template output options' (#30) from issue-29-job-manifest-options into master
Reviewed-on: #30
Reviewed-by: Warky <2+warkanum@noreply@warky.dev>
2026-09-21 04:00:50 +00:00
SG Command ade71b75fa fix(jobs): preserve writer and template output options 2026-09-20 23:16:41 +02:00
warkanum 799b7feb53 ci(rpm): update RPM builder to avoid transient mirrors 2026-09-20 21:41:52 +02:00
warkanum eb40201b59 chore(release): update package version to 1.0.82
Release / release (push) Successful in 1m50s
Release / pkg-aur (push) Successful in 1m0s
Release / pkg-deb (push) Successful in 1m17s
Release / test (push) Successful in 53s
Release / pkg-rpm (push) Failing after 16s
2026-09-20 20:37:41 +02:00
warkanum 1461f8f69f chore(release): update version to 1.0.81 and metadata 2026-09-20 20:37:09 +02:00
warkanum ea70e19a46 feat(jobs): expand environment variables in paths 2026-09-20 20:08:20 +02:00
warkanum 52f4642d97 feat(models): add support for generated and identity columns
Release / test (push) Successful in 3m13s
Release / release (push) Successful in 1m52s
Release / pkg-aur (push) Successful in 1m39s
Release / pkg-deb (push) Successful in 2m15s
Release / pkg-rpm (push) Failing after 46s
* extend Column struct with Generated, GenerationExpression, Identity, and IdentityGeneration fields
* update queryColumns to handle new column properties
* modify migration writer to avoid altering generated and identity columns
* add tests to ensure correct handling of generated and identity columns
2026-09-20 18:33:12 +02:00
warkanum a84592374c Merge pull request 'Feat generated headers column order' (#28) from feat-generated-headers-column-order into master
Release / pkg-rpm (push) Successful in 3m58s
Release / release (push) Successful in 1m54s
Release / test (push) Successful in 5m49s
Release / pkg-aur (push) Successful in 7m13s
Release / pkg-deb (push) Successful in 5m8s
Reviewed-on: #28
2026-09-10 20:22:50 +00:00
warkanum ca226e83df feat(release): update release process to include linting and testing 2026-09-10 22:21:40 +02:00
warkanum 19a2cc1fe3 feat(writers): stamp RelSpec version in generated headers; preserve DBML column order
- Add pkg/buildinfo with Version/BuildDate (set via ldflags, VCS fallback);
  cmd/relspec now sources version info from it
- Emit "RelSpec <version> (built: <date>)" in generated file headers for
  bun, gorm, drizzle, pgsql (incl. migration), mssql, sqlite writers
- pgsql writer: getSortedColumns now sorts by Sequence then Name so the
  streaming WriteSchema/WriteDatabase path preserves source column order
- dbml reader: mergeTable re-bases merged-in column Sequence values past the
  existing max, fixing colliding sequences (and alphabetical fallback) when a
  table is split across multiple DBML files
- Tests for bun/gorm header + column order, and dbml multi-file merge ordering
2026-09-10 22:08:49 +02:00
warkanum 58e46e5b59 feat(dbml): support case-insensitive table note parsing
Release / test (push) Successful in 4m15s
Release / release (push) Successful in 5m29s
Release / pkg-rpm (push) Successful in 2m10s
Release / pkg-deb (push) Successful in 2m27s
Release / pkg-aur (push) Successful in 5m52s
2026-09-09 22:31:00 +02:00
warkanum 161ac317f0 feat(dbml): support multiline table notes in DBML
Release / test (push) Successful in 1m45s
Release / release (push) Successful in 12m24s
Release / pkg-rpm (push) Successful in 2m3s
Release / pkg-deb (push) Successful in 2m17s
Release / pkg-aur (push) Successful in 2m47s
* Add parsing for triple-quoted table notes in DBML
* Update writer to format multiline notes correctly
* Enhance tests for multiline table notes handling
2026-09-09 21:38:11 +02:00
warkanum ee5c009234 feat(main): add silent mode to suppress output messages
* implement --silent flag to control output verbosity
* update error handling to restore stderr after silent mode
* enhance progress reporting in PostgreSQL reader
2026-09-09 21:30:15 +02:00
1088 changed files with 1422613 additions and 4173515 deletions
+63 -13
View File
@@ -63,19 +63,42 @@ jobs:
- name: Build release binaries
run: |
VERSION="${{ github.event.inputs.tag || github.ref_name }}"
BUILD_DATE="$(date -u +"%Y-%m-%d %H:%M:%S UTC")"
LDFLAGS="-X 'git.warky.dev/wdevs/relspecgo/pkg/buildinfo.Version=${VERSION}' -X 'git.warky.dev/wdevs/relspecgo/pkg/buildinfo.BuildDate=${BUILD_DATE}'"
for target in "linux/amd64" "linux/arm64" "darwin/amd64" "darwin/arm64" "windows/amd64"; do
GOOS="${target%/*}"
GOARCH="${target#*/}"
EXT=""
[ "$GOOS" = "windows" ] && EXT=".exe"
NAME="relspec-${GOOS}-${GOARCH}${EXT}"
GOOS="$GOOS" GOARCH="$GOARCH" go build \
GOOS="$GOOS" GOARCH="$GOARCH" CGO_ENABLED=0 go build \
-trimpath \
-ldflags "-X git.warky.dev/wdevs/relspecgo/cmd/relspec.version=${VERSION}" \
-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 }}"
@@ -105,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}" \
@@ -116,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
@@ -129,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"
@@ -167,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
@@ -186,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)
@@ -234,11 +277,12 @@ jobs:
run: |
VERSION="${{ github.event.inputs.tag || github.ref_name }}"
PKGVER="${VERSION#v}"
BUILD_DATE="$(date -u +"%Y-%m-%d %H:%M:%S UTC")"
for GOARCH in amd64 arm64; do
GOOS=linux GOARCH=$GOARCH go build \
-trimpath \
-ldflags "-X git.warky.dev/wdevs/relspecgo/cmd/relspec.version=${PKGVER}" \
-ldflags "-X 'git.warky.dev/wdevs/relspecgo/pkg/buildinfo.Version=v${PKGVER}' -X 'git.warky.dev/wdevs/relspecgo/pkg/buildinfo.BuildDate=${BUILD_DATE}'" \
-o relspec ./cmd/relspec
PKGDIR="relspec_${PKGVER}_${GOARCH}"
@@ -309,7 +353,13 @@ jobs:
rockylinux:9 \
bash -lc "
set -euo pipefail
dnf install -y rpm-build git &&
# Avoid transient/out-of-sync mirrors while bootstrapping the RPM builder.
sed -i \
-e 's|^mirrorlist=|#mirrorlist=|' \
-e 's|^#baseurl=http://dl.rockylinux.org|baseurl=https://dl.rockylinux.org|' \
/etc/yum.repos.d/rocky*.repo
dnf clean all
dnf -y --setopt=timeout=60 --setopt=retries=5 install rpm-build git
curl -fsSL https://go.dev/dl/go\${GO_VER}.linux-amd64.tar.gz | tar -C /usr/local -xz &&
export PATH=\$PATH:/usr/local/go/bin &&
mkdir -p ~/rpmbuild/{BUILD,BUILDROOT,RPMS,SOURCES,SPECS,SRPMS} &&
+59 -113
View File
@@ -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.
+51 -16
View File
@@ -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,13 +16,16 @@ 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 := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
# VERSION file is the single source of truth (bumped by `make release-version`).
# Falls back to git describe for ad-hoc builds where VERSION hasn't been committed yet.
VERSION := $(shell [ -f VERSION ] && echo "v$$(cat VERSION | tr -d '[:space:]')" || git describe --tags --always --dirty 2>/dev/null || echo "dev")
BUILD_DATE := $(shell date -u +"%Y-%m-%d %H:%M:%S UTC")
LDFLAGS := -X 'main.version=$(VERSION)' -X 'main.buildDate=$(BUILD_DATE)'
LDFLAGS := -X 'git.warky.dev/wdevs/relspecgo/pkg/buildinfo.Version=$(VERSION)' -X 'git.warky.dev/wdevs/relspecgo/pkg/buildinfo.BuildDate=$(BUILD_DATE)'
# Auto-detect container runtime (Docker or Podman)
CONTAINER_RUNTIME := $(shell \
@@ -61,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) ./...
@@ -207,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: ## Create and push a new release tag (auto-increments patch version)
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 \
@@ -223,6 +235,15 @@ release: ## Create and push a new release tag (auto-increments patch version)
echo "Creating new release: $$version"; \
commit_logs=$$(git log "$${latest_tag}..HEAD" --pretty=format:"- %s" --no-merges); \
fi; \
PKGVER=$${version#v}; \
echo "Updating package metadata to $$PKGVER..."; \
echo "$$PKGVER" > VERSION; \
sed -i "s/^pkgver=.*/pkgver=$$PKGVER/" linux/arch/relspec/PKGBUILD linux/arch/relspec-bin/PKGBUILD; \
sed -i "s/^Version:.*/Version: $$PKGVER/" 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; \
if [ -z "$$commit_logs" ]; then \
tag_message="Release $$version"; \
else \
@@ -232,21 +253,35 @@ release: ## Create and push a new release tag (auto-increments patch version)
git push origin "$$version"; \
echo "Tag $$version created and pushed to remote repository."
release-version: ## Auto-increment patch version, update package files, commit, tag, and push
@CURRENT=$$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0"); \
MAJOR=$$(echo $$CURRENT | sed 's/v\([0-9]*\)\.\([0-9]*\)\.\([0-9]*\).*/\1/'); \
MINOR=$$(echo $$CURRENT | sed 's/v\([0-9]*\)\.\([0-9]*\)\.\([0-9]*\).*/\2/'); \
PATCH=$$(echo $$CURRENT | sed 's/v\([0-9]*\)\.\([0-9]*\)\.\([0-9]*\).*/\3/'); \
NEXT="v$$MAJOR.$$MINOR.$$((PATCH + 1))"; \
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); \
PATCH=$$(echo $$CURRENT | cut -d. -f3); \
PKGVER="$$MAJOR.$$MINOR.$$((PATCH + 1))"; \
echo "Current: $$CURRENT → Next: $$NEXT"; \
sed -i "s/^pkgver=.*/pkgver=$$PKGVER/" linux/arch/PKGBUILD; \
NEXT="v$$PKGVER"; \
echo "Current: v$$CURRENT → Next: $$NEXT"; \
echo "$$PKGVER" > VERSION; \
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 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
+138 -4
View File
@@ -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
@@ -126,7 +193,7 @@ relspec job run build-schema
version: 1
jobs:
build-schema:
command: convert # closed allow-list: convert | merge | scripts-list
command: convert # closed allow-list: convert | merge | split | scripts-list | scripts-exec | templ | inspect | diff
description: Merge the DBML sources and emit PostgreSQL DDL
inputs:
- path: schema/core.dbml
@@ -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
```
@@ -147,7 +215,43 @@ resolved relative to the job file and may not escape it, and remote database
credentials are referenced by environment-variable name (`conn_env:`) and
redacted from logs. The whole plan — unknown commands/formats, duplicate job
names, missing inputs, path traversal, dependency cycles — is validated before
any job runs. See [docs/JOB_FILES.md](docs/JOB_FILES.md).
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
@@ -170,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+
@@ -180,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
@@ -194,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)
```
@@ -214,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
View File
@@ -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.
+20 -9
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
1.0.91
+2 -1
View File
@@ -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)
}
+215
View File
@@ -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
}
+143
View File
@@ -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
View File
@@ -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))
+312
View File
@@ -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)
}
}
+8 -8
View File
@@ -229,43 +229,43 @@ func readDatabase(dbType, filePath, connString, label string) (*models.Database,
if filePath == "" {
return nil, fmt.Errorf("%s: file path is required for DBML format", label)
}
reader = dbml.NewReader(&readers.ReaderOptions{FilePath: filePath})
reader = dbml.NewReader(newReaderOptions(filePath, ""))
case "dctx":
if filePath == "" {
return nil, fmt.Errorf("%s: file path is required for DCTX format", label)
}
reader = dctx.NewReader(&readers.ReaderOptions{FilePath: filePath})
reader = dctx.NewReader(newReaderOptions(filePath, ""))
case "drawdb":
if filePath == "" {
return nil, fmt.Errorf("%s: file path is required for DrawDB format", label)
}
reader = drawdb.NewReader(&readers.ReaderOptions{FilePath: filePath})
reader = drawdb.NewReader(newReaderOptions(filePath, ""))
case "json":
if filePath == "" {
return nil, fmt.Errorf("%s: file path is required for JSON format", label)
}
reader = json.NewReader(&readers.ReaderOptions{FilePath: filePath})
reader = json.NewReader(newReaderOptions(filePath, ""))
case "yaml":
if filePath == "" {
return nil, fmt.Errorf("%s: file path is required for YAML format", label)
}
reader = yaml.NewReader(&readers.ReaderOptions{FilePath: filePath})
reader = yaml.NewReader(newReaderOptions(filePath, ""))
case "sqldir", "scripts", "scriptdir":
if filePath == "" {
return nil, fmt.Errorf("%s: file path is required for SQL directory format", label)
}
reader = sqldir.NewReader(&readers.ReaderOptions{FilePath: filePath})
reader = sqldir.NewReader(newReaderOptions(filePath, ""))
case "pgsql", "postgres", "postgresql":
if connString == "" {
return nil, fmt.Errorf("%s: connection string is required for PostgreSQL format", label)
}
reader = pgsql.NewReader(&readers.ReaderOptions{ConnectionString: connString})
reader = pgsql.NewReader(newReaderOptions("", connString))
case "sqlite", "sqlite3":
// SQLite can use either file path or connection string
@@ -276,7 +276,7 @@ func readDatabase(dbType, filePath, connString, label string) (*models.Database,
if dbPath == "" {
return nil, fmt.Errorf("%s: file path or connection string is required for SQLite format", label)
}
reader = sqlite.NewReader(&readers.ReaderOptions{FilePath: dbPath})
reader = sqlite.NewReader(newReaderOptions(dbPath, ""))
default:
return nil, fmt.Errorf("%s: unsupported database format: %s", label, dbType)
+158
View File
@@ -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)
}
}
+78 -20
View File
@@ -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"
@@ -60,8 +61,9 @@ inputs, output and options:
logfile: .relspec/log/build-schema.log
Rules and guarantees:
- command is a closed allow-list (convert, merge, scripts-list, templ). Arbitrary
shell strings are never executed.
- command is a closed allow-list (convert, merge, split, scripts-list,
scripts-exec, templ, inspect, diff). Arbitrary shell strings are never
executed.
- Every path is relative to the directory holding the job file and may not
escape it. Absolute and home-relative paths are rejected.
- Remote database credentials are referenced by environment-variable name
@@ -246,22 +248,37 @@ type resolvedInput struct {
func preflightJob(j *jobs.Job, resolvedByName map[string]*resolvedJob) (*resolvedJob, error) {
root := j.Dir()
rj := &resolvedJob{job: j, root: root, logPolicy: j.ResolvedLogPolicy()}
expandPath := func(label, value string) (string, error) {
expanded, err := jobs.ExpandEnv(value)
if err != nil {
return "", fmt.Errorf("%s: %w", label, err)
}
return expanded, nil
}
if j.Logfile != "" {
p, err := jobs.SafeJoin(root, j.Logfile)
logfile, err := expandPath("logfile", j.Logfile)
if err != nil {
return nil, err
}
p, err := jobs.SafeJoin(root, logfile)
if err != nil {
return nil, fmt.Errorf("logfile: %w", err)
}
rj.logPath = p
}
if j.Template != "" {
p, err := jobs.SafeJoin(root, j.Template)
templatePath, err := expandPath("template", j.Template)
if err != nil {
return nil, err
}
p, err := jobs.SafeJoin(root, templatePath)
if err != nil {
return nil, fmt.Errorf("template: %w", err)
}
info, err := os.Stat(p)
if err != nil || info.IsDir() {
return nil, fmt.Errorf("template %q: not found or is a directory", j.Template)
return nil, fmt.Errorf("template %q: not found or is a directory", templatePath)
}
rj.templatePath = p
}
@@ -291,16 +308,20 @@ func preflightJob(j *jobs.Job, resolvedByName map[string]*resolvedJob) (*resolve
ri.connEnv = in.ConnEnv
rj.secrets = append(rj.secrets, v)
} else {
p, err := jobs.SafeJoin(root, in.Path)
inputPath, err := expandPath(fmt.Sprintf("input[%d]", i), in.Path)
if err != nil {
return nil, err
}
p, err := jobs.SafeJoin(root, inputPath)
if err != nil {
return nil, fmt.Errorf("input[%d]: %w", i, err)
}
info, err := os.Stat(p)
if err != nil {
return nil, fmt.Errorf("input[%d]: %s: file not found", i, in.Path)
return nil, fmt.Errorf("input[%d]: %s: file not found", i, inputPath)
}
if info.IsDir() {
return nil, fmt.Errorf("input[%d]: %s: is a directory, not a file", i, in.Path)
return nil, fmt.Errorf("input[%d]: %s: is a directory, not a file", i, inputPath)
}
ri.path = p
}
@@ -308,13 +329,17 @@ func preflightJob(j *jobs.Job, resolvedByName map[string]*resolvedJob) (*resolve
}
for _, d := range j.ScriptDirs {
p, err := jobs.SafeJoin(root, d)
scriptDir, err := expandPath("script_dir", d)
if err != nil {
return nil, err
}
p, err := jobs.SafeJoin(root, scriptDir)
if err != nil {
return nil, fmt.Errorf("script_dir %q: %w", d, err)
}
info, err := os.Stat(p)
if err != nil {
return nil, fmt.Errorf("script_dir %q: not found", d)
return nil, fmt.Errorf("script_dir %q: not found", scriptDir)
}
if !info.IsDir() {
return nil, fmt.Errorf("script_dir %q: not a directory", d)
@@ -332,25 +357,33 @@ func preflightJob(j *jobs.Job, resolvedByName map[string]*resolvedJob) (*resolve
rj.outputConnEnv = j.Output.ConnEnv
rj.secrets = append(rj.secrets, v)
} else {
p, err := jobs.SafeJoin(root, j.Output.Path)
outputPath, err := expandPath("output", j.Output.Path)
if err != nil {
return nil, err
}
p, err := jobs.SafeJoin(root, outputPath)
if err != nil {
return nil, fmt.Errorf("output: %w", err)
}
if _, err := os.Stat(p); err == nil && !j.Output.Overwrite {
return nil, fmt.Errorf("output %s already exists (set output.overwrite: true to replace it)", j.Output.Path)
return nil, fmt.Errorf("output %s already exists (set output.overwrite: true to replace it)", outputPath)
}
rj.outputPath = p
}
}
if j.Rules != "" {
p, err := jobs.SafeJoin(root, j.Rules)
rulesPath, err := expandPath("rules", j.Rules)
if err != nil {
return nil, err
}
p, err := jobs.SafeJoin(root, rulesPath)
if err != nil {
return nil, fmt.Errorf("rules: %w", err)
}
info, err := os.Stat(p)
if err != nil || info.IsDir() {
return nil, fmt.Errorf("rules %q: not found or is a directory", j.Rules)
return nil, fmt.Errorf("rules %q: not found or is a directory", rulesPath)
}
rj.rulesPath = p
}
@@ -358,12 +391,16 @@ func preflightJob(j *jobs.Job, resolvedByName map[string]*resolvedJob) (*resolve
if j.Report != nil {
rj.reportFormat = strings.ToLower(j.Report.Format)
if j.Report.Path != "" {
p, err := jobs.SafeJoin(root, j.Report.Path)
reportPath, err := expandPath("report", j.Report.Path)
if err != nil {
return nil, err
}
p, err := jobs.SafeJoin(root, reportPath)
if err != nil {
return nil, fmt.Errorf("report: %w", err)
}
if _, err := os.Stat(p); err == nil && !j.Report.Overwrite {
return nil, fmt.Errorf("report %s already exists (set report.overwrite: true to replace it)", j.Report.Path)
return nil, fmt.Errorf("report %s already exists (set report.overwrite: true to replace it)", reportPath)
}
rj.reportPath = p
}
@@ -486,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"
@@ -542,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)
}
@@ -740,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,
@@ -754,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
@@ -778,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
}
@@ -797,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 != "" {
@@ -805,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)
}
@@ -816,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
+164
View File
@@ -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)
}
}
+82
View File
@@ -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)
}
}
+35 -2
View File
@@ -5,10 +5,43 @@ import (
"os"
)
// asciiLogo (see version.go) is printed by the `version` command.
func main() {
printVersionHeader(os.Args[1:])
if err := rootCmd.Execute(); err != nil {
args := os.Args[1:]
isSilent := hasSilentFlag(args)
if !isSilent {
printVersionHeader(args)
}
previousStderr := os.Stderr
var nullOutput *os.File
if isSilent {
var err error
nullOutput, err = os.OpenFile(os.DevNull, os.O_WRONLY, 0)
if err != nil {
fmt.Fprintln(previousStderr, err)
os.Exit(1)
}
os.Stderr = nullOutput
}
err := rootCmd.Execute()
if nullOutput != nil {
os.Stderr = previousStderr
nullOutput.Close()
}
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
func hasSilentFlag(args []string) bool {
for _, arg := range args {
if arg == "--silent" || arg == "--silent=true" {
return true
}
}
return false
}
+54 -3
View File
@@ -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)")
}
@@ -178,7 +183,7 @@ func runMerge(cmd *cobra.Command, args []string) error {
}
// Step 1: Read target database
fmt.Fprintf(os.Stderr, "[1/3] Reading target database...\n")
fmt.Fprintf(os.Stderr, "[1/4] Reading target database...\n")
fmt.Fprintf(os.Stderr, " Format: %s\n", mergeTargetType)
if mergeTargetPath != "" {
fmt.Fprintf(os.Stderr, " Path: %s\n", mergeTargetPath)
@@ -195,7 +200,7 @@ func runMerge(cmd *cobra.Command, args []string) error {
printDatabaseStats(targetDB)
// Step 2: Read source database(s)
fmt.Fprintf(os.Stderr, "\n[2/3] Reading source database...\n")
fmt.Fprintf(os.Stderr, "\n[2/4] Reading source database...\n")
fmt.Fprintf(os.Stderr, " Format: %s\n", mergeSourceType)
var sourceDB *models.Database
@@ -229,7 +234,7 @@ func runMerge(cmd *cobra.Command, args []string) error {
printDatabaseStats(sourceDB)
// Step 3: Merge databases
fmt.Fprintf(os.Stderr, "\n[3/3] Merging databases...\n")
fmt.Fprintf(os.Stderr, "\n[3/4] Merging databases...\n")
opts := &merge.MergeOptions{
SkipDomains: mergeSkipDomains,
@@ -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,12 +267,38 @@ 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)
if mergeOutputPath != "" {
fmt.Fprintf(os.Stderr, " Path: %s\n", mergeOutputPath)
}
if mergeOutputConn != "" {
fmt.Fprintf(os.Stderr, " Conn: %s\n", maskPassword(mergeOutputConn))
}
err = writeDatabaseForMerge(mergeOutputType, mergeOutputPath, mergeOutputConn, targetDB, "Output", mergeFlattenSchema)
if err != nil {
@@ -279,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
@@ -366,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) {
@@ -439,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)
}
}
+17 -8
View File
@@ -1,6 +1,9 @@
package main
import (
"fmt"
"os"
"git.warky.dev/wdevs/relspecgo/pkg/readers"
"git.warky.dev/wdevs/relspecgo/pkg/writers"
)
@@ -11,18 +14,24 @@ func newReaderOptions(filePath, connString string) *readers.ReaderOptions {
ConnectionString: connString,
Prisma7: prisma7,
StrictDirectives: strictDirectives,
Progress: func(message string) {
fmt.Fprintf(os.Stderr, " → %s\n", message)
},
}
}
func newWriterOptions(outputPath, packageName string, flattenSchema bool, nullableTypes, nullableArrays string, continueOnError bool) *writers.WriterOptions {
return &writers.WriterOptions{
OutputPath: outputPath,
PackageName: packageName,
FlattenSchema: flattenSchema,
NullableTypes: nullableTypes,
NullableArrays: nullableArrays,
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,
}
}
+29 -38
View File
@@ -2,51 +2,28 @@ package main
import (
"fmt"
"runtime/debug"
"time"
"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 information, set via ldflags during build
version = "dev"
buildDate = "unknown"
prisma7 bool
noVersion 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
)
func init() {
// If version wasn't set via ldflags, try to get it from build info
if version == "dev" {
if info, ok := debug.ReadBuildInfo(); ok {
// Try to get version from VCS
var vcsRevision, vcsTime string
for _, setting := range info.Settings {
switch setting.Key {
case "vcs.revision":
if len(setting.Value) >= 7 {
vcsRevision = setting.Value[:7]
}
case "vcs.time":
vcsTime = setting.Value
}
}
if vcsRevision != "" {
version = vcsRevision
}
if vcsTime != "" {
if t, err := time.Parse(time.RFC3339, vcsTime); err == nil {
buildDate = t.UTC().Format("2006-01-02 15:04:05 UTC")
}
}
}
}
}
var rootCmd = &cobra.Command{
Use: "relspec",
Short: "RelSpec - Database schema conversion and analysis tool",
@@ -56,23 +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:, …)")
}
+86
View File
@@ -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)
}
}
+3 -1
View File
@@ -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 {
+23
View File
@@ -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)
+41
View File
@@ -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)
}
}
+9
View File
@@ -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)
+148
View File
@@ -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()
}
+110
View File
@@ -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")
}
}
+5 -2
View File
@@ -4,13 +4,16 @@ import (
"fmt"
"github.com/spf13/cobra"
"git.warky.dev/wdevs/relspecgo/pkg/buildinfo"
)
var versionCmd = &cobra.Command{
Use: "version",
Short: "Print version information",
Run: func(cmd *cobra.Command, args []string) {
fmt.Printf("RelSpec %s\n", version)
fmt.Printf("Built: %s\n", buildDate)
fmt.Print(buildinfo.AsciiLogo)
fmt.Printf("RelSpec %s\n", buildinfo.Version)
fmt.Printf("Built: %s\n", buildinfo.BuildDate)
},
}
+152
View File
@@ -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
}
+92
View File
@@ -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")
}
+103
View File
@@ -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).
+15 -2
View File
@@ -55,6 +55,10 @@ already forbid duplicate keys within a single file.
`script_dirs[]`, `template`, `logfile`) is **relative to the directory
containing the job file that declared the job**, not the process working
directory.
* Those path fields support `${NAME}` environment-variable references. They
are expanded immediately before execution; a missing variable is an error.
The expanded value is still subject to all relative-path and symlink safety
checks.
* Absolute paths, `~`-relative paths and any path that resolves outside the job
file directory (`../`, `a/../../b`, …) are **rejected during validation** —
before anything runs.
@@ -183,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
@@ -201,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
+131
View File
@@ -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
View File
@@ -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
+10
View File
@@ -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.
+212
View File
@@ -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
View File
@@ -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.
+80
View File
@@ -0,0 +1,80 @@
# RelSpec job examples
Run these commands from this directory:
```bash
cd examples/jobs
# List every job from relspec.yml and the additional relspec.*.yml files.
relspec job list
# Validate a job and print its resolved plan without reading or writing data.
relspec job run build-schema --plan
# Build PostgreSQL DDL from the two DBML inputs.
relspec job run build-schema
# Run a job's dependency chain. This runs build-schema first, then build-json.
relspec job run build-json
# Inspect build-json's output. The from_job input adds the dependency automatically.
relspec job run lint-schema
# Extract only the posts table.
relspec job run posts-only
# List migration scripts in deterministic order without connecting to a database.
relspec job run migration-order
# Render one TypeScript file per table using the example template.
relspec job run generate-typescript
# Compare the two example schemas and write the summary to the logfile.
relspec job run compare-schemas
# Use an environment variable in an output path.
export VST=build/typescript
relspec job run generate-typescript --plan
relspec job run generate-typescript
```
Database jobs use environment variables for connection strings. Set them in
the shell before running the jobs; do not put connection strings in YAML:
```bash
export SOURCE_DB_URL='postgres://user:password@localhost/source_db'
export TARGET_DB_URL='postgres://user:password@localhost/target_db'
# Read the source database and write a local DBML snapshot.
relspec job run snapshot-database
# Preview migration execution without connecting to PostgreSQL.
relspec job run apply-migrations --plan
# Execute the migrations against TARGET_DB_URL.
relspec job run apply-migrations
```
The `apply-migrations` job demonstrates execution from multiple directories:
```yaml
script_dirs:
- migrations/core
- migrations/tenant
output:
format: pgsql
conn_env: TARGET_DB_URL
```
RelSpec combines the SQL files from both folders in deterministic order before
executing them against the configured PostgreSQL database.
To use a different directory or manifest, pass `--dir` or `--file`:
```bash
relspec job list --dir /path/to/project
relspec job run build-schema --file /path/to/project/relspec.yml --plan
```
See [../../docs/JOB_FILES.md](../../docs/JOB_FILES.md) for the complete job
file reference.
+29
View File
@@ -0,0 +1,29 @@
# Database-oriented examples. Set the referenced environment variables before
# running these jobs; connection strings never belong in a job file.
version: 1
jobs:
snapshot-database:
command: convert
description: Read a PostgreSQL database and write a DBML snapshot
inputs:
- format: pgsql
conn_env: SOURCE_DB_URL
output:
format: dbml
path: build/database-snapshot.dbml
overwrite: true
apply-migrations:
command: scripts-exec
description: Execute migrations from core and tenant folders against PostgreSQL
# All SQL files from these directories are discovered in deterministic order.
script_dirs:
- migrations/core
- migrations/tenant
output:
format: pgsql
conn_env: TARGET_DB_URL
options:
continue_on_error: false
logfile: .relspec/log/apply-migrations.log
+32
View File
@@ -0,0 +1,32 @@
# Reporting and templating examples. These jobs are discovered together with
# relspec.yml when running `relspec job list` from this directory.
version: 1
jobs:
generate-typescript:
command: templ
description: Render one TypeScript file per table from the merged schema
inputs:
- path: schema/core.dbml
format: dbml
- path: schema/tenant.dbml
format: dbml
template: templates/schema.tmpl
mode: table
filename_pattern: "{{.Name}}.ts"
output:
format: text
path: "${VST}"
overwrite: true
compare-schemas:
command: diff
description: Compare the two example schemas and print a summary
inputs:
- path: schema/core.dbml
format: dbml
- path: schema/tenant.dbml
format: dbml
report:
format: summary
logfile: .relspec/log/compare-schemas.log
+6
View File
@@ -0,0 +1,6 @@
// Code generated by RelSpec. DO NOT EDIT.
export interface {{.Name}} {
{{- range values .Table.Columns}}
{{.Name}}: {{.Type}};
{{- end}}
}
+23 -23
View File
@@ -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
)
+66 -63
View File
@@ -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=
+24
View File
@@ -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,31 +1,31 @@
# Maintainer: Hein (Warky Devs) <hein@warky.dev>
pkgname=relspec
pkgver=1.0.74
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 \
-ldflags "-X git.warky.dev/wdevs/relspecgo/cmd/relspec.version=$pkgver" \
-ldflags "-X 'git.warky.dev/wdevs/relspecgo/pkg/buildinfo.Version=v$pkgver' -X 'git.warky.dev/wdevs/relspecgo/pkg/buildinfo.BuildDate=$(date -u +"%Y-%m-%d %H:%M:%S UTC")'" \
-o "$pkgname" ./cmd/relspec
}
check() {
cd "relspecgo"
cd "$pkgname-$pkgver"
go test ./...
}
package() {
cd "relspecgo"
cd "$pkgname-$pkgver"
# Binary
install -Dm755 "$pkgname" "$pkgdir/usr/bin/$pkgname"
+2 -2
View File
@@ -1,5 +1,5 @@
Name: relspec
Version: 1.0.74
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.
@@ -25,7 +25,7 @@ DBML, GraphQL, and more.
export CGO_ENABLED=0
go build \
-trimpath \
-ldflags "-X git.warky.dev/wdevs/relspecgo/cmd/relspec.version=%{version}" \
-ldflags "-X 'git.warky.dev/wdevs/relspecgo/pkg/buildinfo.Version=v%{version}' -X 'git.warky.dev/wdevs/relspecgo/pkg/buildinfo.BuildDate=$(date -u +"%%Y-%%m-%%d %%H:%%M:%%S UTC")'" \
-o %{name} ./cmd/relspec
%install
+93 -18
View File
@@ -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
}
+111
View File
@@ -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)
}
}
+5 -18
View File
@@ -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)
}
+36
View File
@@ -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
View File
@@ -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
}
+60
View File
@@ -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")
}
}
}
+65
View File
@@ -0,0 +1,65 @@
// Package buildinfo exposes the RelSpec version and build date so that both the
// CLI and the schema writers can stamp generated output with the same values.
package buildinfo
import (
"fmt"
"runtime/debug"
"time"
)
// Version and BuildDate are set via -ldflags at build time (see Makefile). When
// built without ldflags they are backfilled from the Go module build info.
var (
Version = "dev"
BuildDate = "unknown"
)
func init() {
if Version != "dev" {
return
}
info, ok := debug.ReadBuildInfo()
if !ok {
return
}
var rev, vcsTime string
for _, s := range info.Settings {
switch s.Key {
case "vcs.revision":
if len(s.Value) >= 7 {
rev = s.Value[:7]
}
case "vcs.time":
vcsTime = s.Value
}
}
if rev != "" {
Version = rev
}
if t, err := time.Parse(time.RFC3339, vcsTime); err == nil {
BuildDate = t.UTC().Format("2006-01-02 15:04:05 UTC")
}
}
// GeneratedComment returns the one-line provenance string embedded in generated
// files, e.g. "RelSpec dev (built: unknown)".
func GeneratedComment() string {
return fmt.Sprintf("RelSpec %s (built: %s)", Version, BuildDate)
}
const AsciiLogo = `
██████╗ ███████╗██╗ ███████╗██████╗ ███████╗ ██████╗
██╔══██╗██╔════╝██║ ██╔════╝██╔══██╗██╔════╝██╔════╝
██████╔╝█████╗ ██║ ███████╗██████╔╝█████╗ ██║
██╔══██╗██╔══╝ ██║ ╚════██║██╔═══╝ ██╔══╝ ██║
██║ ██║███████╗███████╗███████║██║ ███████╗╚██████╗
╚═╝ ╚═╝╚══════╝╚══════╝╚══════╝╚═╝ ╚══════╝ ╚═════╝
[ IN ] ──▶ [ RELSPEC ] ──▶ [ OUT ]
╔══════════════════════════════════════╗
║ ║
║ © WARKY DEVS ║
║ Author: Hein (hein@warky.dev) ║
║ ║
╚══════════════════════════════════════╝
`
+20
View File
@@ -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
}
+345
View File
@@ -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)
}
}
+63
View File
@@ -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
View File
@@ -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
+1
View File
@@ -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
+1
View File
@@ -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]
+5
View File
@@ -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",
},
},
}
}
+1
View File
@@ -37,6 +37,7 @@ func TestGetDefaultConfig(t *testing.T) {
"missing_primary_key",
"orphaned_foreign_key",
"circular_dependency",
"duplicate_index_name",
}
for _, ruleName := range expectedRules {
+61
View File
@@ -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
}
+81
View File
@@ -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)
}
}
}
+53 -9
View File
@@ -19,6 +19,7 @@ import (
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
@@ -276,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:
@@ -471,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,
@@ -568,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 {
@@ -850,6 +871,29 @@ func SafeJoin(root, rel string) (string, error) {
return joined, nil
}
var envReferencePattern = regexp.MustCompile(`\$\{([A-Za-z_][A-Za-z0-9_]*)\}`)
// ExpandEnv expands ${NAME} references using the current process environment.
// It deliberately supports only shell-independent variable references; job
// files are never passed through a shell. An unset variable is an error so a
// typo cannot silently turn into a relative path.
func ExpandEnv(value string) (string, error) {
var missing string
expanded := envReferencePattern.ReplaceAllStringFunc(value, func(reference string) string {
name := reference[2 : len(reference)-1]
resolved, ok := os.LookupEnv(name)
if !ok {
missing = name
return reference
}
return resolved
})
if missing != "" {
return "", fmt.Errorf("environment variable %q referenced by ${%s} is not set", missing, missing)
}
return expanded, nil
}
// deepestExistingAncestor returns p itself if it exists, otherwise the nearest
// existing parent directory (falling back to the filesystem root).
func deepestExistingAncestor(p string) string {
+15
View File
@@ -135,6 +135,21 @@ func TestParseHumanSize(t *testing.T) {
}
}
func TestExpandEnv(t *testing.T) {
t.Setenv("RELSPEC_TEST_ROOT", "build/output")
got, err := ExpandEnv("${RELSPEC_TEST_ROOT}/schema-${RELSPEC_TEST_ROOT}")
if err != nil {
t.Fatal(err)
}
if want := "build/output/schema-build/output"; got != want {
t.Fatalf("ExpandEnv = %q, want %q", got, want)
}
if _, err := ExpandEnv("build/${RELSPEC_TEST_MISSING}"); err == nil {
t.Fatal("expected missing environment variable error")
}
}
func TestLoadRejectsDuplicateJobAcrossFiles(t *testing.T) {
dir := t.TempDir()
a := filepath.Join(dir, "relspec.yml")
+262
View File
@@ -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
View File
@@ -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)"
}
+13
View File
@@ -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
View File
@@ -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
}
+277
View File
@@ -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")
}
}
+58
View File
@@ -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")
}
}
+29
View File
@@ -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")
}
}
+48
View File
@@ -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
}
+59
View File
@@ -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)
}
})
}
}
+35 -19
View File
@@ -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
@@ -227,23 +228,27 @@ func (d *Sequence) SQLName() string {
// Column represents a table column
type Column struct {
Name string `json:"name" yaml:"name" xml:"name"`
Description string `json:"description,omitempty" yaml:"description,omitempty" xml:"description,omitempty"`
Table string `json:"table" yaml:"table" xml:"table"`
Schema string `json:"schema" yaml:"schema" xml:"schema"`
Type string `json:"type" yaml:"type" xml:"type"`
Length int `json:"length,omitempty" yaml:"length,omitempty" xml:"length,omitempty"`
Precision int `json:"precision,omitempty" yaml:"precision,omitempty" xml:"precision,omitempty"`
Scale int `json:"scale,omitempty" yaml:"scale,omitempty" xml:"scale,omitempty"`
NotNull bool `json:"not_null" yaml:"not_null" xml:"not_null"`
Default any `json:"default,omitempty" yaml:"default,omitempty" xml:"default,omitempty"`
AutoIncrement bool `json:"auto_increment" yaml:"auto_increment" xml:"auto_increment"`
IsPrimaryKey bool `json:"is_primary_key" yaml:"is_primary_key" xml:"is_primary_key"`
Comment string `json:"comment,omitempty" yaml:"comment,omitempty" xml:"comment,omitempty"`
Collation string `json:"collation,omitempty" yaml:"collation,omitempty" xml:"collation,omitempty"`
Metadata map[string]any `json:"metadata,omitempty" yaml:"metadata,omitempty" xml:"-"`
Sequence uint `json:"sequence,omitempty" yaml:"sequence,omitempty" xml:"sequence,omitempty"`
GUID string `json:"guid" yaml:"guid" xml:"guid"`
Name string `json:"name" yaml:"name" xml:"name"`
Description string `json:"description,omitempty" yaml:"description,omitempty" xml:"description,omitempty"`
Table string `json:"table" yaml:"table" xml:"table"`
Schema string `json:"schema" yaml:"schema" xml:"schema"`
Type string `json:"type" yaml:"type" xml:"type"`
Length int `json:"length,omitempty" yaml:"length,omitempty" xml:"length,omitempty"`
Precision int `json:"precision,omitempty" yaml:"precision,omitempty" xml:"precision,omitempty"`
Scale int `json:"scale,omitempty" yaml:"scale,omitempty" xml:"scale,omitempty"`
NotNull bool `json:"not_null" yaml:"not_null" xml:"not_null"`
Default any `json:"default,omitempty" yaml:"default,omitempty" xml:"default,omitempty"`
AutoIncrement bool `json:"auto_increment" yaml:"auto_increment" xml:"auto_increment"`
IsPrimaryKey bool `json:"is_primary_key" yaml:"is_primary_key" xml:"is_primary_key"`
Comment string `json:"comment,omitempty" yaml:"comment,omitempty" xml:"comment,omitempty"`
Collation string `json:"collation,omitempty" yaml:"collation,omitempty" xml:"collation,omitempty"`
Metadata map[string]any `json:"metadata,omitempty" yaml:"metadata,omitempty" xml:"-"`
Sequence uint `json:"sequence,omitempty" yaml:"sequence,omitempty" xml:"sequence,omitempty"`
GUID string `json:"guid" yaml:"guid" xml:"guid"`
Generated bool `json:"generated,omitempty" yaml:"generated,omitempty" xml:"generated,omitempty"`
GenerationExpression string `json:"generation_expression,omitempty" yaml:"generation_expression,omitempty" xml:"generation_expression,omitempty"`
Identity bool `json:"identity,omitempty" yaml:"identity,omitempty" xml:"identity,omitempty"`
IdentityGeneration string `json:"identity_generation,omitempty" yaml:"identity_generation,omitempty" xml:"identity_generation,omitempty"` // "ALWAYS" or "BY DEFAULT"
}
// SQLName returns the column name in lowercase for SQL compatibility.
@@ -251,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"`
@@ -313,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"`
@@ -324,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"`
}
+233
View File
@@ -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")
}
}
+171
View File
@@ -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")
}
}
+249
View File
@@ -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)
}
})
}
}
+2
View File
@@ -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
+48
View File
@@ -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")
}
}
+117
View File
@@ -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)
}
}
+7
View File
@@ -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
+43 -1
View File
@@ -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
+218
View File
@@ -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
}
+260
View File
@@ -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)
}
}
}
+38
View File
@@ -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
}
+221 -27
View File
@@ -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
}
@@ -305,9 +321,39 @@ func sortDBMLFiles(files []string) []string {
// Merges: Columns (map), Constraints (map), Indexes (map), Relationships (map)
// Uses first non-empty Description
func mergeTable(baseTable, fileTable *models.Table) {
// Merge columns (map naturally merges - later keys overwrite)
for key, col := range fileTable.Columns {
baseTable.Columns[key] = col
// Merge columns. Each file numbers its own columns from 1, so a table split
// across files would otherwise end up with colliding Column.Sequence values
// and writers would fall back to alphabetical order. Re-base the incoming
// file's new columns after the highest sequence already present, preserving
// their in-file order. Columns that overwrite an existing key keep the
// original position.
var maxSeq uint
for _, col := range baseTable.Columns {
if col.Sequence > maxSeq {
maxSeq = col.Sequence
}
}
incoming := make([]*models.Column, 0, len(fileTable.Columns))
for _, col := range fileTable.Columns {
incoming = append(incoming, col)
}
sort.Slice(incoming, func(i, j int) bool {
if incoming[i].Sequence != incoming[j].Sequence {
return incoming[i].Sequence < incoming[j].Sequence
}
return incoming[i].Name < incoming[j].Name
})
var added uint
for _, col := range incoming {
if existing, ok := baseTable.Columns[col.Name]; ok {
col.Sequence = existing.Sequence
} else {
added++
col.Sequence = maxSeq + added
}
baseTable.Columns[col.Name] = col
}
// Merge constraints
@@ -410,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
@@ -434,6 +484,9 @@ func (r *Reader) parseDBML(content string) (*models.Database, error) {
var currentSchema string
var inIndexes bool
var inTable bool
var inTableNote bool
var tableNoteLines []string
tableNoteStartLine := 0
var columnSeq uint
var lastIndex *models.Index // most recent index in the current Indexes block
lineNo := 0
@@ -443,10 +496,29 @@ func (r *Reader) parseDBML(content string) (*models.Database, error) {
for scanner.Scan() {
lineNo++
line := strings.TrimSpace(scanner.Text())
rawLine := scanner.Text()
line := strings.TrimSpace(rawLine)
// Skip empty lines and comments
// A table note can use DBML's triple-quoted form. Its contents must be
// consumed before normal parsing, otherwise each prose line is mistaken
// for a column declaration.
if inTableNote {
if line == "'''" {
setTableNote(currentTable, strings.TrimSpace(strings.Join(tableNoteLines, "\n")))
inTableNote = false
tableNoteLines = nil
continue
}
tableNoteLines = append(tableNoteLines, strings.TrimSpace(rawLine))
continue
}
// 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
}
@@ -533,17 +605,30 @@ 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
}
// Parse table note
if inTable && currentTable != nil && strings.HasPrefix(line, "Note:") {
note := strings.TrimPrefix(line, "Note:")
// Parse table note. DBML files in the wild use both `Note:` and
// `note:`, so accept either spelling.
if inTable && currentTable != nil && strings.HasPrefix(strings.ToLower(line), "note:") {
note := strings.TrimSpace(line[len("note:"):])
if strings.TrimSpace(note) == "'''" {
inTableNote = true
tableNoteLines = nil
tableNoteStartLine = lineNo
continue
}
note = strings.Trim(note, " '\"")
currentTable.Description = note
setTableNote(currentTable, note)
continue
}
@@ -580,6 +665,13 @@ func (r *Reader) parseDBML(content string) (*models.Database, error) {
}
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("failed to scan DBML: %w", err)
}
if inTableNote {
return nil, fmt.Errorf("dbml: line %d: unterminated triple-quoted table note", tableNoteStartLine)
}
// Assign pending constraints to their respective tables
for _, constraint := range pendingConstraints {
// Find the table this constraint belongs to
@@ -597,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)
}
}
}
@@ -623,6 +707,53 @@ 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) {
if table.Description == "" {
table.Description = note
return
}
if table.Comment == "" {
table.Comment = note
return
}
table.Comment += "\n" + note
}
// parseColumn parses a DBML column definition
func (r *Reader) parseColumn(line, tableName, schemaName string) (*models.Column, *models.Constraint) {
// Format: column_name type [attributes] // comment
@@ -640,7 +771,7 @@ func (r *Reader) parseColumn(line, tableName, schemaName string) (*models.Column
// Parse attributes in brackets
if attrs != "" {
attrList := strings.Split(attrs, ",")
attrList := splitColumnAttrs(attrs)
for _, attr := range attrList {
attr = strings.TrimSpace(attr)
@@ -673,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:
@@ -715,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
@@ -723,6 +863,44 @@ func (r *Reader) parseColumn(line, tableName, schemaName string) (*models.Column
return column, constraint
}
// splitColumnAttrs splits a DBML attribute list on top-level commas. Notes and
// quoted defaults may contain commas of their own, which are part of the value
// rather than attribute separators.
func splitColumnAttrs(attrs string) []string {
var result []string
start := 0
var quote byte
escaped := false
for i := 0; i < len(attrs); i++ {
ch := attrs[i]
if quote != 0 {
if escaped {
escaped = false
continue
}
if ch == '\\' {
escaped = true
continue
}
if ch == quote {
quote = 0
}
continue
}
switch ch {
case '\'', '"', '`':
quote = ch
case ',':
result = append(result, attrs[start:i])
start = i + 1
}
}
return append(result, attrs[start:])
}
func splitInlineComment(line string) (content, inlineComment string) {
commentStart := strings.Index(line, "//")
if commentStart == -1 {
@@ -910,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
@@ -1011,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)
}
}
}
@@ -1019,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
+158 -3
View File
@@ -3,6 +3,7 @@ package dbml
import (
"os"
"path/filepath"
"strings"
"testing"
"git.warky.dev/wdevs/relspecgo/pkg/models"
@@ -652,6 +653,22 @@ func TestReadDirectory_TableMerging(t *testing.T) {
if emailCol.Type != "varchar(255)" {
t.Errorf("Expected email type 'varchar(255)', got '%s'", emailCol.Type)
}
// Merged columns must keep declaration order (file 1: id, email; file 3:
// name, created_at) via strictly increasing, non-colliding Sequence values
// so downstream writers do not fall back to alphabetical order.
order := []string{"id", "email", "name", "created_at"}
var prev uint
for i, name := range order {
col := usersTable.Columns[name]
if col.Sequence == 0 {
t.Fatalf("column %q has zero Sequence after merge", name)
}
if i > 0 && col.Sequence <= prev {
t.Errorf("column %q Sequence %d not greater than previous %d (order not preserved across files)", name, col.Sequence, prev)
}
prev = col.Sequence
}
}
func TestReadDirectory_CommentedRefsLast(t *testing.T) {
@@ -826,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))
}
@@ -1033,3 +1050,141 @@ func TestReader_ColumnPKOrderPreserved(t *testing.T) {
t.Errorf("expected snapshot_id (declared first) to have a lower Sequence than artifact_id, got %d >= %d", snapshotCol.Sequence, artifactCol.Sequence)
}
}
func TestReader_MultilineTableNote(t *testing.T) {
dbmlContent := "Table \"info\".\"city\" {\n" +
" \"id_city\" serial [pk, not null, increment]\n" +
" \"name\" text [not null, note: 'first, second, third']\n\n" +
" note: '''\n" +
" Cities and municipalities worldwide.\n\n" +
" SPATIAL:\n" +
" Proximity queries use a GiST index.\n" +
" '''\n" +
" Note: 'Short summary'\n" +
"}\n"
path := filepath.Join(t.TempDir(), "city.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 got, want := table.Description, "Cities and municipalities worldwide.\n\nSPATIAL:\nProximity queries use a GiST index."; got != want {
t.Errorf("table description = %q, want %q", got, want)
}
if got, want := table.Comment, "Short summary"; got != want {
t.Errorf("table comment = %q, want %q", got, want)
}
if got, want := len(table.Columns), 2; got != want {
t.Errorf("column count = %d, want %d; note body must not be parsed as columns", got, want)
}
if got, want := table.Columns["name"].Comment, "first, second, third"; got != want {
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)
}
}
+1
View File
@@ -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
+74 -1
View File
@@ -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
}
+114
View File
@@ -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")
}
}
+2
View 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
+48
View File
@@ -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")
}
}
+164
View File
@@ -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)
}
})
}
}
+9
View File
@@ -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
+5
View File
@@ -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
+37 -2
View File
@@ -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])
}
+15
View File
@@ -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)
}
}
}

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