Compare commits

..
4 Commits
Author SHA1 Message Date
warkanum 9235292586 fix(crud): skip generated and read-only columns on insert and update
Tests / Integration Tests (push) Skipped
Build , Vet Test, and Lint / Build (push) Successful in 1m34s
Tests / Unit Tests (push) Successful in 1m42s
Build , Vet Test, and Lint / Lint Code (push) Successful in 2m11s
Build , Vet Test, and Lint / Run Vet Tests (1.24.x) (push) Successful in 2m25s
Build , Vet Test, and Lint / Run Vet Tests (1.23.x) (push) Successful in 2m28s
Tests / Race Detector (push) Successful in 4m6s
Read-merge-update wrote every model column back, so GENERATED ALWAYS
columns failed with SQLSTATE 428C9. Add a bun 'generated' tag option,
reflection.NonWritableColumns/RemoveNonWritableColumns, and apply them
in resolvespec, restheadspec, websocketspec, mqttspec, resolvemcp and
the nested CUD processor. Add ExcludeColumn to InsertQuery/UpdateQuery
for model-based writes.
2026-10-02 22:43:45 +02:00
warkanum 23f10387c5 ci(release): fix rust toolchain setup and dart publish validation warnings
Tests / Integration Tests (push) Skipped
Build , Vet Test, and Lint / Build (push) Successful in 3m15s
Tests / Unit Tests (push) Successful in 3m26s
Build , Vet Test, and Lint / Lint Code (push) Successful in 4m45s
Build , Vet Test, and Lint / Run Vet Tests (1.24.x) (push) Successful in 4m55s
Build , Vet Test, and Lint / Run Vet Tests (1.23.x) (push) Successful in 4m55s
Tests / Race Detector (push) Successful in 7m6s
2026-10-01 21:20:04 +02:00
warkanum 0d3ad9e4fd ci(tests): disable integration tests job and install psql client 2026-10-01 21:16:34 +02:00
warkanum f5d232d971 ci: move workflows to Gitea and add client release workflow
Tests / Integration Tests (push) Failing after 1m39s
Build , Vet Test, and Lint / Build (push) Successful in 2m3s
Tests / Unit Tests (push) Successful in 2m8s
Build , Vet Test, and Lint / Lint Code (push) Successful in 2m51s
Build , Vet Test, and Lint / Run Vet Tests (1.24.x) (push) Successful in 2m57s
Build , Vet Test, and Lint / Run Vet Tests (1.23.x) (push) Successful in 2m59s
Tests / Race Detector (push) Successful in 5m9s
Move .github/workflows to .gitea/workflows with Gitea-compatible action
versions, fix make_tag outputs/major bump, and add release_clients.yml to
build and publish all clients to the Gitea package registries. Rename the Go
client module to git.warky.dev/wdevs and add LICENSE/CHANGELOG for Dart.
2026-10-01 21:11:41 +02:00
24 changed files with 523 additions and 33 deletions
@@ -1,6 +1,3 @@
# This workflow will build a golang project
# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-go
name: Create Go Release (Tag Versioning)
on:
@@ -26,7 +23,9 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v2
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Git
run: |
@@ -38,7 +37,7 @@ jobs:
run: |
git fetch --tags
latest_tag=$(git describe --tags `git rev-list --tags --max-count=1`)
echo "::set-output name=tag::$latest_tag"
echo "tag=${latest_tag}" >> "${GITHUB_OUTPUT}"
- name: Determine new tag version
id: new_tag
@@ -57,7 +56,7 @@ jobs:
((minor++))
patch=0
;;
"release")
"major")
((major++))
minor=0
patch=0
@@ -68,15 +67,11 @@ jobs:
;;
esac
new_tag="v$major.$minor.$patch"
echo "::set-output name=tag::$new_tag"
echo "tag=${new_tag}" >> "${GITHUB_OUTPUT}"
- name: Create tag
run: |
git tag -a ${{ steps.new_tag.outputs.tag }} -m "Tagging ${{ steps.new_tag.outputs.tag }} for release"
- name: Push changes
uses: ad-m/github-push-action@master
with:
github_token: ${{ secrets.BITECH_GITHUB_TOKEN }}
force: true
tags: true
- name: Push tag
run: git push origin ${{ steps.new_tag.outputs.tag }}
+268
View File
@@ -0,0 +1,268 @@
name: Release Clients
on:
workflow_dispatch:
inputs:
version:
description: "Client version (e.g. 1.4.0)"
required: true
type: string
publish:
description: "Publish packages to Gitea (untick for a build/test dry run)"
required: true
default: true
type: boolean
env:
VERSION_INPUT: ${{ github.event.inputs.version }}
PUBLISH: ${{ github.event.inputs.publish }}
SERVER_URL: ${{ github.server_url }}
OWNER: ${{ github.repository_owner }}
REGISTRY_USER: ${{ secrets.PACKAGE_REGISTRY_USERNAME || vars.PACKAGE_REGISTRY_USERNAME }}
TOKEN: ${{ secrets.PACKAGE_REGISTRY_TOKEN || vars.PACKAGE_REGISTRY_TOKEN }}
jobs:
validate:
name: Validate version
runs-on: ubuntu-latest
outputs:
version: ${{ steps.v.outputs.version }}
steps:
- id: v
run: |
version="${VERSION_INPUT#v}"
if ! [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then
echo "Invalid version: $VERSION_INPUT" >&2
exit 1
fi
echo "version=${version}" >> "${GITHUB_OUTPUT}"
js:
name: JS (npm)
needs: validate
runs-on: ubuntu-latest
defaults:
run:
shell: bash
working-directory: clients/resolvespec-js
env:
VERSION: ${{ needs.validate.outputs.version }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
- name: Enable pnpm
run: corepack enable
- name: Install
run: pnpm install --frozen-lockfile
- name: Test
run: pnpm test
- name: Set version
run: npm version "$VERSION" --no-git-tag-version --allow-same-version
- name: Build
run: pnpm build
- name: Publish
if: ${{ env.PUBLISH == 'true' }}
run: |
host="${SERVER_URL#*://}"
registry="${SERVER_URL}/api/packages/${OWNER}/npm/"
npm config set "@warkypublic:registry" "$registry"
npm config set "//${host}/api/packages/${OWNER}/npm/:_authToken" "$TOKEN"
npm publish --registry "$registry"
python:
name: Python (PyPI)
needs: validate
runs-on: ubuntu-latest
defaults:
run:
shell: bash
working-directory: clients/resolvespec-python
env:
VERSION: ${{ needs.validate.outputs.version }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install
run: pip install -e ".[dev]" build twine
- name: Test
run: pytest
- name: Set version
run: sed -i -E "s/^version = \".*\"/version = \"${VERSION}\"/" pyproject.toml
- name: Build
run: python -m build
- name: Publish
if: ${{ env.PUBLISH == 'true' }}
run: |
twine upload \
--repository-url "${SERVER_URL}/api/packages/${OWNER}/pypi" \
-u "$REGISTRY_USER" -p "$TOKEN" \
dist/*
rust:
name: Rust (Cargo)
needs: validate
runs-on: ubuntu-latest
defaults:
run:
shell: bash
working-directory: clients/resolvespec-rs
env:
VERSION: ${{ needs.validate.outputs.version }}
steps:
- uses: actions/checkout@v4
- name: Set up Rust
uses: dtolnay/rust-toolchain@stable
- name: Test
run: cargo test
- name: Set version
run: sed -i -E '0,/^version = ".*"/s//version = "'"${VERSION}"'"/' Cargo.toml
- name: Package
run: cargo package --allow-dirty
- name: Publish
if: ${{ env.PUBLISH == 'true' }}
env:
CARGO_REGISTRIES_GITEA_INDEX: sparse+${{ github.server_url }}/api/packages/${{ github.repository_owner }}/cargo/
run: |
export CARGO_REGISTRIES_GITEA_TOKEN="Bearer ${TOKEN}"
cargo publish --registry gitea --allow-dirty
dotnet:
name: C# (NuGet)
needs: validate
runs-on: ubuntu-latest
defaults:
run:
shell: bash
working-directory: clients/resolvespec-cs
env:
VERSION: ${{ needs.validate.outputs.version }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: "8.0.x"
- name: Test
run: dotnet test tests/ResolveSpec.Tests.csproj
- name: Pack
run: dotnet pack src/ResolveSpec.csproj -c Release -p:Version="$VERSION" -o out
- name: Publish
if: ${{ env.PUBLISH == 'true' }}
run: |
dotnet nuget push out/*.nupkg \
--source "${SERVER_URL}/api/packages/${OWNER}/nuget/index.json" \
--api-key "$TOKEN" \
--skip-duplicate
go:
name: Go (Go registry)
needs: validate
runs-on: ubuntu-latest
defaults:
run:
shell: bash
working-directory: clients/resolvespec-go
env:
VERSION: ${{ needs.validate.outputs.version }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: clients/resolvespec-go/go.mod
- name: Test
run: go test ./...
- name: Build module zip
run: |
python3 - <<'PY'
import os, re, zipfile
version = "v" + os.environ["VERSION"]
module = re.search(r"^module\s+(\S+)", open("go.mod").read(), re.M).group(1)
prefix = f"{module}@{version}/"
with zipfile.ZipFile("../resolvespec-go.zip", "w", zipfile.ZIP_DEFLATED) as z:
for root, dirs, files in os.walk("."):
dirs[:] = [d for d in dirs if d != ".git"]
for f in files:
path = os.path.join(root, f)
z.write(path, prefix + os.path.relpath(path, "."))
PY
- name: Publish
if: ${{ env.PUBLISH == 'true' }}
run: |
curl -f -X PUT \
--user "${REGISTRY_USER}:${TOKEN}" \
--upload-file ../resolvespec-go.zip \
"${SERVER_URL}/api/packages/${OWNER}/go/upload"
dart:
name: Dart (Pub)
needs: validate
runs-on: ubuntu-latest
defaults:
run:
shell: bash
working-directory: clients/resolvespec-dart
env:
VERSION: ${{ needs.validate.outputs.version }}
steps:
- uses: actions/checkout@v4
- uses: dart-lang/setup-dart@v1
- name: Install
run: dart pub get
- name: Analyze
run: dart analyze
- name: Test
run: dart test
- name: Set version and registry
run: |
sed -i -E "s/^version: .*/version: ${VERSION}/" pubspec.yaml
sed -i -E "s#^publish_to: .*#publish_to: ${SERVER_URL}/api/packages/${OWNER}/pub#" pubspec.yaml
if ! grep -q "^## ${VERSION}\$" CHANGELOG.md; then
{ head -n 1 CHANGELOG.md; printf '\n## %s\n\n- Release %s.\n' "$VERSION" "$VERSION"; tail -n +2 CHANGELOG.md; } > CHANGELOG.tmp
mv CHANGELOG.tmp CHANGELOG.md
fi
# pub warns about a dirty git tree; commit the stamped files locally (never pushed)
git -c user.name=ci -c user.email=ci@localhost commit -q -am "ci: stamp dart version ${VERSION}"
- name: Dry run
if: ${{ env.PUBLISH != 'true' }}
run: dart pub publish --dry-run
- name: Publish
if: ${{ env.PUBLISH == 'true' }}
run: |
dart pub token add "${SERVER_URL}/api/packages/${OWNER}/pub" --env-var TOKEN
dart pub publish --force
@@ -9,9 +9,9 @@ jobs:
name: Unit Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v6
uses: actions/setup-go@v5
with:
go-version: "1.24"
- name: Run unit tests
@@ -22,7 +22,7 @@ jobs:
go test ./pkg/resolvespec ./pkg/restheadspec -coverprofile=coverage.out
go tool cover -html=coverage.out -o coverage.html
- name: Upload coverage
uses: actions/upload-artifact@v5
uses: actions/upload-artifact@v3
continue-on-error: true
with:
name: coverage-report
@@ -31,15 +31,16 @@ jobs:
name: Race Detector
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v6
uses: actions/setup-go@v5
with:
go-version: "1.24"
- name: Run unit tests with the race detector
run: go test -race -count=1 ./pkg/...
integration-tests:
name: Integration Tests
if: false # disabled for now
runs-on: ubuntu-latest
services:
postgres:
@@ -56,46 +57,51 @@ jobs:
ports:
- 5432:5432
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v6
uses: actions/setup-go@v5
with:
go-version: "1.24"
- name: Install PostgreSQL client
run: |
SUDO=""; [ "$(id -u)" -ne 0 ] && SUDO="sudo"
$SUDO apt-get update -qq
$SUDO apt-get install -y -qq postgresql-client
- name: Create test databases
env:
PGPASSWORD: postgres
run: |
psql -h localhost -U postgres -c "CREATE DATABASE resolvespec_test;"
psql -h localhost -U postgres -c "CREATE DATABASE restheadspec_test;"
psql -h postgres -U postgres -c "CREATE DATABASE resolvespec_test;"
psql -h postgres -U postgres -c "CREATE DATABASE restheadspec_test;"
- name: Run resolvespec integration tests
continue-on-error: true
env:
TEST_DATABASE_URL: "host=localhost user=postgres password=postgres dbname=resolvespec_test port=5432 sslmode=disable"
TEST_DATABASE_URL: "host=postgres user=postgres password=postgres dbname=resolvespec_test port=5432 sslmode=disable"
run: go test -tags=integration ./pkg/resolvespec -v -coverprofile=coverage-resolvespec-integration.out
- name: Run restheadspec integration tests
continue-on-error: true
env:
TEST_DATABASE_URL: "host=localhost user=postgres password=postgres dbname=restheadspec_test port=5432 sslmode=disable"
TEST_DATABASE_URL: "host=postgres user=postgres password=postgres dbname=restheadspec_test port=5432 sslmode=disable"
run: go test -tags=integration ./pkg/restheadspec -v -coverprofile=coverage-restheadspec-integration.out
- name: Generate integration coverage
continue-on-error: true
env:
TEST_DATABASE_URL: "host=localhost user=postgres password=postgres dbname=resolvespec_test port=5432 sslmode=disable"
TEST_DATABASE_URL: "host=postgres user=postgres password=postgres dbname=resolvespec_test port=5432 sslmode=disable"
run: |
go tool cover -html=coverage-resolvespec-integration.out -o coverage-resolvespec-integration.html
go tool cover -html=coverage-restheadspec-integration.out -o coverage-restheadspec-integration.html
- name: Upload resolvespec integration coverage
uses: actions/upload-artifact@v5
uses: actions/upload-artifact@v3
continue-on-error: true
with:
name: resolvespec-integration-coverage-report
path: coverage-resolvespec-integration.html
- name: Upload restheadspec integration coverage
uses: actions/upload-artifact@v5
uses: actions/upload-artifact@v3
continue-on-error: true
with:
name: integration-coverage-restheadspec-report
path: coverage-restheadspec-integration
path: coverage-restheadspec-integration.html
+5
View File
@@ -0,0 +1,5 @@
# Changelog
## 0.1.0
- Initial release: ResolveSpec (JSON body) and FunctionSpec client.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Hein
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+1
View File
@@ -1,6 +1,7 @@
name: resolvespec
description: Client for ResolveSpec (JSON body) and FunctionSpec endpoints.
version: 0.1.0
repository: https://git.warky.dev/wdevs/ResolveSpec
publish_to: none
environment:
+1 -1
View File
@@ -1,6 +1,6 @@
# resolvespec-go
Go client for ResolveSpec (JSON body) and FunctionSpec. Module: `github.com/bitechdev/ResolveSpec/clients/resolvespec-go`. Stdlib only.
Go client for ResolveSpec (JSON body) and FunctionSpec. Module: `git.warky.dev/wdevs/ResolveSpec/clients/resolvespec-go`. Stdlib only.
## Clients
+1 -1
View File
@@ -1,3 +1,3 @@
module github.com/bitechdev/ResolveSpec/clients/resolvespec-go
module git.warky.dev/wdevs/ResolveSpec/clients/resolvespec-go
go 1.22
+14
View File
@@ -1507,6 +1507,13 @@ func (b *BunInsertQuery) OnConflict(action string) common.InsertQuery {
return b
}
func (b *BunInsertQuery) ExcludeColumn(columns ...string) common.InsertQuery {
if len(columns) > 0 {
b.query = b.query.ExcludeColumn(columns...)
}
return b
}
func (b *BunInsertQuery) Returning(columns ...string) common.InsertQuery {
if len(columns) > 0 {
b.query = b.query.Returning(strings.Join(columns, ", "))
@@ -1619,6 +1626,13 @@ func (b *BunUpdateQuery) SetMap(values map[string]interface{}) common.UpdateQuer
return b
}
func (b *BunUpdateQuery) ExcludeColumn(columns ...string) common.UpdateQuery {
if len(columns) > 0 {
b.query = b.query.ExcludeColumn(columns...)
}
return b
}
func (b *BunUpdateQuery) Where(query string, args ...interface{}) common.UpdateQuery {
b.query = b.query.Where(query, args...)
return b
+14
View File
@@ -751,6 +751,13 @@ func (g *GormInsertQuery) OnConflict(action string) common.InsertQuery {
return g
}
func (g *GormInsertQuery) ExcludeColumn(columns ...string) common.InsertQuery {
if len(columns) > 0 {
g.db = g.db.Omit(columns...)
}
return g
}
func (g *GormInsertQuery) Returning(columns ...string) common.InsertQuery {
g.returningColumns = columns
return g
@@ -930,6 +937,13 @@ func (g *GormUpdateQuery) SetMap(values map[string]interface{}) common.UpdateQue
return g
}
func (g *GormUpdateQuery) ExcludeColumn(columns ...string) common.UpdateQuery {
if len(columns) > 0 {
g.db = g.db.Omit(columns...)
}
return g
}
func (g *GormUpdateQuery) Where(query string, args ...interface{}) common.UpdateQuery {
g.db = g.db.Where(query, args...)
return g
+14
View File
@@ -691,6 +691,13 @@ func (p *PgSQLInsertQuery) OnConflict(action string) common.InsertQuery {
return p
}
func (p *PgSQLInsertQuery) ExcludeColumn(columns ...string) common.InsertQuery {
for _, col := range columns {
delete(p.values, col)
}
return p
}
func (p *PgSQLInsertQuery) Returning(columns ...string) common.InsertQuery {
p.returning = columns
return p
@@ -850,6 +857,13 @@ func (p *PgSQLUpdateQuery) Set(column string, value interface{}) common.UpdateQu
return p
}
func (p *PgSQLUpdateQuery) ExcludeColumn(columns ...string) common.UpdateQuery {
for _, col := range columns {
delete(p.sets, col)
}
return p
}
func (p *PgSQLUpdateQuery) SetMap(values map[string]interface{}) common.UpdateQuery {
pkName := ""
if p.model != nil {
+4
View File
@@ -81,6 +81,8 @@ type InsertQuery interface {
Table(table string) InsertQuery
Value(column string, value interface{}) InsertQuery
OnConflict(action string) InsertQuery
// ExcludeColumn omits columns from a Model()-based INSERT (e.g. generated columns).
ExcludeColumn(columns ...string) InsertQuery
Returning(columns ...string) InsertQuery
// Execution
@@ -94,6 +96,8 @@ type UpdateQuery interface {
Table(table string) UpdateQuery
Set(column string, value interface{}) UpdateQuery
SetMap(values map[string]interface{}) UpdateQuery
// ExcludeColumn omits columns from a Model()-based UPDATE (e.g. generated columns).
ExcludeColumn(columns ...string) UpdateQuery
Where(query string, args ...interface{}) UpdateQuery
Returning(columns ...string) UpdateQuery
+6 -2
View File
@@ -116,7 +116,7 @@ func (p *NestedCUDProcessor) ProcessNestedCUD(
case "insert", "create", "add":
// Only perform insert if we have data to insert
if hasData {
id, err := p.processInsert(ctx, regularData, tableName)
id, err := p.processInsert(ctx, regularData, model, tableName)
if err != nil {
logger.Error("Insert failed for table=%s, data=%+v, error=%v", tableName, regularData, err)
return nil, fmt.Errorf("insert failed: %w", err)
@@ -148,7 +148,7 @@ func (p *NestedCUDProcessor) ProcessNestedCUD(
return result, nil
}
if hasData {
rows, err := p.processUpdate(ctx, regularData, tableName, data[pkName])
rows, err := p.processUpdate(ctx, regularData, model, tableName, data[pkName])
if err != nil {
logger.Error("Update failed for table=%s, id=%v, data=%+v, error=%v", tableName, data[pkName], regularData, err)
return nil, fmt.Errorf("update failed: %w", err)
@@ -295,10 +295,12 @@ func (p *NestedCUDProcessor) injectForeignKeys(data map[string]interface{}, mode
func (p *NestedCUDProcessor) processInsert(
ctx context.Context,
data map[string]interface{},
model interface{},
tableName string,
) (interface{}, error) {
logger.Debug("Inserting into %s with data: %+v", tableName, data)
reflection.RemoveNonWritableColumns(model, data)
query := p.db.NewInsert().Table(tableName)
for key, value := range data {
@@ -335,6 +337,7 @@ func (p *NestedCUDProcessor) processSelect(ctx context.Context, tableName string
func (p *NestedCUDProcessor) processUpdate(
ctx context.Context,
data map[string]interface{},
model interface{},
tableName string,
id interface{},
) (int64, error) {
@@ -345,6 +348,7 @@ func (p *NestedCUDProcessor) processUpdate(
logger.Debug("Updating %s with ID %v, data: %+v", tableName, id, data)
reflection.RemoveNonWritableColumns(model, data)
query := p.db.NewUpdate().Table(tableName).SetMap(data).Where(fmt.Sprintf("%s = ?", QuoteIdent(reflection.GetPrimaryKeyName(tableName))), id)
result, err := query.Exec(ctx)
+2
View File
@@ -99,6 +99,7 @@ func (m *mockInsertQuery) Value(column string, value interface{}) InsertQuery {
return m
}
func (m *mockInsertQuery) OnConflict(action string) InsertQuery { return m }
func (m *mockInsertQuery) ExcludeColumn(columns ...string) InsertQuery { return m }
func (m *mockInsertQuery) Returning(columns ...string) InsertQuery { return m }
func (m *mockInsertQuery) Exec(ctx context.Context) (Result, error) {
m.db.insertCalls = append(m.db.insertCalls, m.values)
@@ -131,6 +132,7 @@ func (m *mockUpdateQuery) SetMap(values map[string]interface{}) UpdateQuery {
return m
}
func (m *mockUpdateQuery) Where(condition string, args ...interface{}) UpdateQuery { return m }
func (m *mockUpdateQuery) ExcludeColumn(columns ...string) UpdateQuery { return m }
func (m *mockUpdateQuery) Returning(columns ...string) UpdateQuery { return m }
func (m *mockUpdateQuery) Exec(ctx context.Context) (Result, error) {
// Record the update call
+5
View File
@@ -895,6 +895,9 @@ func (h *Handler) create(hookCtx *HookContext) (interface{}, error) {
// Insert record
query := hookCtx.Tx.NewInsert().Model(hookCtx.ModelPtr).Table(hookCtx.TableName)
if generated := reflection.NonWritableColumns(hookCtx.Model); len(generated) > 0 {
query = query.ExcludeColumn(generated...)
}
if _, err := query.Exec(hookCtx.Context); err != nil {
return nil, fmt.Errorf("failed to create record: %w", err)
}
@@ -924,6 +927,8 @@ func (h *Handler) update(hookCtx *HookContext) error {
// the stored value unless disallowNulls is set, in which case null is skipped.
values := common.MergeUpdateValues(make(map[string]interface{}, len(updates)), updates, h.disallowNulls)
reflection.RemoveNonWritableColumns(hookCtx.Model, values)
if len(values) > 0 {
query := hookCtx.Tx.NewUpdate().Table(hookCtx.TableName).SetMap(values).
Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), hookCtx.ID)
+65 -1
View File
@@ -656,7 +656,7 @@ func isColumnWritableInType(typ reflect.Type, columnName string) (found bool, wr
// Check bun tag for scanonly
bunTag := field.Tag.Get("bun")
if bunTag != "" {
if isBunFieldScanOnly(bunTag) {
if isBunFieldScanOnly(bunTag) || isBunFieldGenerated(bunTag) {
return true, false
}
}
@@ -689,6 +689,70 @@ func isBunFieldScanOnly(tag string) bool {
return false
}
// isBunFieldGenerated checks if a bun tag marks the column as database-generated
// (GENERATED ALWAYS AS ... STORED), which can be read but never written.
// Example: "email_normalized,generated" -> true
func isBunFieldGenerated(tag string) bool {
for _, part := range strings.Split(tag, ",") {
if strings.TrimSpace(part) == "generated" {
return true
}
}
return false
}
// RemoveNonWritableColumns deletes from values every key that maps to a
// non-writable model column (bun scanonly/generated, gorm read-only). Used
// before writing a read-merged record back with UPDATE ... SET.
func RemoveNonWritableColumns(model any, values map[string]interface{}) {
for key := range values {
if !IsColumnWritable(model, key) {
delete(values, key)
}
}
}
// NonWritableColumns returns the column names of the model that cannot be
// written (bun scanonly/generated, gorm read-only), including embedded structs.
func NonWritableColumns(model any) []string {
t := reflect.TypeOf(model)
for t != nil && (t.Kind() == reflect.Pointer || t.Kind() == reflect.Slice || t.Kind() == reflect.Array) {
t = t.Elem()
}
if t == nil || t.Kind() != reflect.Struct {
return nil
}
var cols []string
collectNonWritable(t, &cols)
return cols
}
func collectNonWritable(typ reflect.Type, cols *[]string) {
for i := 0; i < typ.NumField(); i++ {
field := typ.Field(i)
if field.Anonymous {
ft := field.Type
if ft.Kind() == reflect.Pointer {
ft = ft.Elem()
}
if ft.Kind() == reflect.Struct {
collectNonWritable(ft, cols)
continue
}
}
bunTag, gormTag := field.Tag.Get("bun"), field.Tag.Get("gorm")
if bunTag == "-" || gormTag == "-" {
continue
}
if (bunTag != "" && (isBunFieldScanOnly(bunTag) || isBunFieldGenerated(bunTag))) ||
(gormTag != "" && isGormFieldReadOnly(gormTag)) {
if name := getColumnNameFromField(field); name != "" {
*cols = append(*cols, name)
}
}
}
}
// isGormFieldReadOnly checks if a gorm tag indicates the field is read-only
// Examples:
// - "<-:false" -> true (no writes allowed)
+43
View File
@@ -1920,3 +1920,46 @@ func TestMapToStruct_Errors(t *testing.T) {
})
}
}
func TestRemoveNonWritableColumns_Generated(t *testing.T) {
type m struct {
ID int `bun:"id,pk"`
Email string `bun:"email"`
Norm string `bun:"email_normalized,generated"`
Scan string `bun:"scan_col,scanonly"`
}
vals := map[string]interface{}{"id": 1, "email": "A", "email_normalized": "a", "scan_col": "x", "dynamic": 1}
RemoveNonWritableColumns(&m{}, vals)
if _, ok := vals["email_normalized"]; ok {
t.Error("generated column not removed")
}
if _, ok := vals["scan_col"]; ok {
t.Error("scanonly column not removed")
}
if len(vals) != 3 {
t.Errorf("unexpected keys: %v", vals)
}
}
func TestNonWritableColumns(t *testing.T) {
type base struct {
Created string `bun:"created_at,scanonly"`
}
type m struct {
base
ID int `bun:"id,pk"`
Email string `bun:"email"`
Norm string `bun:"email_normalized,generated"`
Ro string `gorm:"column:ro;->"`
}
got := NonWritableColumns(&m{})
want := map[string]bool{"created_at": true, "email_normalized": true, "ro": true}
if len(got) != len(want) {
t.Fatalf("got %v", got)
}
for _, c := range got {
if !want[c] {
t.Errorf("unexpected %s", c)
}
}
}
+2
View File
@@ -559,6 +559,7 @@ func (h *Handler) executeCreate(ctx context.Context, schema, entity string, data
if len(cols) == 0 {
return invalidArg("no writable fields in data")
}
reflection.RemoveNonWritableColumns(model, cols)
q := tx.NewInsert().Table(tableName)
for key, value := range cols {
q = q.Value(key, value)
@@ -726,6 +727,7 @@ func (h *Handler) executeUpdate(ctx context.Context, schema, entity, id string,
existingMap[key] = v
}
reflection.RemoveNonWritableColumns(model, setCols)
q := tx.NewUpdate().Table(tableName).SetMap(setCols).
Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), id)
res, err := q.Exec(ctx)
+1
View File
@@ -194,6 +194,7 @@ func (h *Handler) executeWhere(ctx context.Context, req whereRequest) (_ *whereR
cond := fmt.Sprintf("%s IN (%s)", common.QuoteIdent(pkName), strings.Join(inList, ", "))
var affected int64
if req.op == "update" {
reflection.RemoveNonWritableColumns(model, setCols)
r, err := tx.NewUpdate().Table(tableName).SetMap(setCols).Where(cond, ids...).Exec(ctx)
if err != nil {
return fmt.Errorf("error updating records: %w", err)
+6
View File
@@ -824,6 +824,7 @@ func (h *Handler) handleCreate(ctx context.Context, w common.ResponseWriter, dat
}
responseData = v
reflection.RemoveNonWritableColumns(model, v)
query := tx.NewInsert().Table(tableName)
for key, value := range v {
query = query.Value(key, common.ConvertSliceForBun(value))
@@ -971,6 +972,7 @@ func (h *Handler) handleCreate(ctx context.Context, w common.ResponseWriter, dat
item = modifiedData
}
reflection.RemoveNonWritableColumns(model, item)
txQuery := tx.NewInsert().Table(tableName)
for key, value := range item {
txQuery = txQuery.Value(key, common.ConvertSliceForBun(value))
@@ -1127,6 +1129,7 @@ func (h *Handler) handleCreate(ctx context.Context, w common.ResponseWriter, dat
itemMap = modifiedData
}
reflection.RemoveNonWritableColumns(model, itemMap)
txQuery := tx.NewInsert().Table(tableName)
for key, value := range itemMap {
txQuery = txQuery.Value(key, common.ConvertSliceForBun(value))
@@ -1322,6 +1325,7 @@ func (h *Handler) handleUpdate(ctx context.Context, w common.ResponseWriter, url
// Overwrite with every key present in the request (including "" and null unless disallowed)
common.MergeUpdateValues(existingMap, updates, h.disallowNulls)
reflection.RemoveNonWritableColumns(model, existingMap)
// Build update query with merged data
query := tx.NewUpdate().Table(tableName).SetMap(existingMap)
@@ -1507,6 +1511,7 @@ func (h *Handler) handleUpdate(ctx context.Context, w common.ResponseWriter, url
// Overwrite with every key present in the request (including "" and null unless disallowed)
common.MergeUpdateValues(existingMap, item, h.disallowNulls)
reflection.RemoveNonWritableColumns(model, existingMap)
txQuery := tx.NewUpdate().Table(tableName).SetMap(existingMap).Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), itemID)
if _, err := txQuery.Exec(ctx); err != nil {
@@ -1662,6 +1667,7 @@ func (h *Handler) handleUpdate(ctx context.Context, w common.ResponseWriter, url
// Overwrite with every key present in the request (including "" and null unless disallowed)
common.MergeUpdateValues(existingMap, itemMap, h.disallowNulls)
reflection.RemoveNonWritableColumns(model, existingMap)
txQuery := tx.NewUpdate().Table(tableName).SetMap(existingMap).Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), itemID)
if _, err := txQuery.Exec(ctx); err != nil {
+6
View File
@@ -1410,6 +1410,9 @@ func (h *Handler) handleCreate(ctx context.Context, w common.ResponseWriter, dat
if provider, ok := modelValue.(common.TableNameProvider); !ok || provider.TableName() == "" {
query = query.Table(tableName)
}
if generated := reflection.NonWritableColumns(model); len(generated) > 0 {
query = query.ExcludeColumn(generated...)
}
fields := reflection.GetSQLModelColumns(model)
query = query.Returning(fields...)
@@ -1657,6 +1660,9 @@ func (h *Handler) handleUpdate(ctx context.Context, w common.ResponseWriter, id
// Create update query using Model() to preserve custom types and driver.Valuer interfaces
query := tx.NewUpdate().Model(modelInstance)
if generated := reflection.NonWritableColumns(model); len(generated) > 0 {
query = query.ExcludeColumn(generated...)
}
query = query.Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), targetID)
// Execute BeforeScan hooks - pass query chain so hooks can modify it
+5
View File
@@ -758,6 +758,9 @@ func (h *Handler) create(hookCtx *HookContext) (interface{}, error) {
// Insert record
query := hookCtx.Tx.NewInsert().Model(hookCtx.ModelPtr).Table(hookCtx.TableName)
if generated := reflection.NonWritableColumns(hookCtx.Model); len(generated) > 0 {
query = query.ExcludeColumn(generated...)
}
if _, err := query.Exec(hookCtx.Context); err != nil {
return nil, fmt.Errorf("failed to create record: %w", err)
}
@@ -786,6 +789,8 @@ func (h *Handler) update(hookCtx *HookContext) error {
// the stored value unless disallowNulls is set, in which case null is skipped.
values := common.MergeUpdateValues(make(map[string]interface{}, len(updates)), updates, h.disallowNulls)
reflection.RemoveNonWritableColumns(hookCtx.Model, values)
if len(values) > 0 {
query := hookCtx.Tx.NewUpdate().Table(hookCtx.TableName).SetMap(values).
Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), hookCtx.ID)
+10
View File
@@ -226,6 +226,11 @@ func (m *MockInsertQuery) OnConflict(action string) common.InsertQuery {
return args.Get(0).(common.InsertQuery)
}
func (m *MockInsertQuery) ExcludeColumn(columns ...string) common.InsertQuery {
args := m.Called(columns)
return args.Get(0).(common.InsertQuery)
}
func (m *MockInsertQuery) Returning(columns ...string) common.InsertQuery {
args := m.Called(columns)
return args.Get(0).(common.InsertQuery)
@@ -254,6 +259,11 @@ func (m *MockUpdateQuery) Model(model interface{}) common.UpdateQuery {
return args.Get(0).(common.UpdateQuery)
}
func (m *MockUpdateQuery) ExcludeColumn(columns ...string) common.UpdateQuery {
args := m.Called(columns)
return args.Get(0).(common.UpdateQuery)
}
func (m *MockUpdateQuery) Table(table string) common.UpdateQuery {
args := m.Called(table)
return args.Get(0).(common.UpdateQuery)