Compare commits

...

4 Commits

Author SHA1 Message Date
Hein
289cd74485 feat(database): Enhance Preload and Join functionality
Some checks failed
Build , Vet Test, and Lint / Run Vet Tests (1.23.x) (push) Successful in -22m33s
Build , Vet Test, and Lint / Run Vet Tests (1.24.x) (push) Successful in -22m11s
Build , Vet Test, and Lint / Build (push) Successful in -26m39s
Build , Vet Test, and Lint / Lint Code (push) Successful in -25m53s
Tests / Integration Tests (push) Failing after -27m30s
Tests / Unit Tests (push) Successful in -27m5s
* Introduce skipAutoDetect flag to prevent circular calls in PreloadRelation.
* Improve handling of long alias chains in PreloadRelation.
* Ensure JoinRelation uses PreloadRelation without causing recursion.
* Clear deferred preloads after execution to prevent re-execution.

feat(recursive_crud):  Filter valid fields in nested CUD processing

* Add filterValidFields method to validate input data against model structure.
* Use reflection to ensure only valid fields are processed.

feat(reflection):  Add utility to get valid JSON field names

* Implement GetValidJSONFieldNames to retrieve valid JSON field names from model.
* Enhance field validation during nested CUD operations.

fix(handler): 🐛 Adjust recursive preload depth limit

* Change recursive preload depth limit from 5 to 4 to prevent excessive recursion.
2026-01-14 17:02:26 +02:00
Hein
c75842ebb0 feat(dbmanager): update health check interval and add tests
* Change default health check interval from 30s to 15s.
* Always start background health checks regardless of auto-reconnect setting.
* Add tests for health checker functionality and default configurations.
2026-01-14 15:04:27 +02:00
Hein
7879272dda fix(recursive_crud): 🐛 prevent overwriting primary key in recursive relationships
* Ensure foreign key assignment does not overwrite primary key in recursive relationships.
* Added logging for skipped assignments to improve debugging.
2026-01-14 10:19:04 +02:00
Hein
292306b608 fix: 🎨 fix recursive crud bugs 2026-01-14 10:00:13 +02:00
11 changed files with 1415 additions and 106 deletions

1
go.mod
View File

@@ -116,7 +116,6 @@ require (
github.com/shirou/gopsutil/v4 v4.25.6 // indirect
github.com/shopspring/decimal v1.4.0 // indirect
github.com/sirupsen/logrus v1.9.3 // indirect
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect

59
go.sum
View File

@@ -88,8 +88,6 @@ github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/getsentry/sentry-go v0.40.0 h1:VTJMN9zbTvqDqPwheRVLcp0qcUcM+8eFivvGocAaSbo=
github.com/getsentry/sentry-go v0.40.0/go.mod h1:eRXCoh3uvmjQLY6qu63BjUZnaBu5L5WhMV1RwYO8W5s=
github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ=
github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc=
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
@@ -107,17 +105,15 @@ github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9L
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/golang-jwt/jwt/v5 v5.0.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/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/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs=
github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
@@ -145,8 +141,6 @@ 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.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY=
github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw=
github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo=
github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
@@ -164,8 +158,6 @@ github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkr
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46/go.mod h1:yyMNCyc/Ib3bDTKd379tNMpB/7/H5TjM2Y9QJ5THLbE=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
@@ -183,8 +175,6 @@ github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8S
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=
github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mattn/go-sqlite3 v1.14.33 h1:A5blZ5ulQo2AtayQ9/limgHEkFreKj1Dv226a1K73s0=
github.com/mattn/go-sqlite3 v1.14.33/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/microsoft/go-mssqldb v1.8.2/go.mod h1:vp38dT33FGfVotRiTmDo3bFyaHq+p3LektQrjTULowo=
@@ -246,18 +236,12 @@ github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs=
github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
github.com/prometheus/common v0.67.4 h1:yR3NqWO1/UyO1w2PhUvXlGQs/PtFmoveVO0KZ4+Lvsc=
github.com/prometheus/common v0.67.4/go.mod h1:gP0fq6YjjNCLssJCQp0yk4M8W6ikLURwkdd/YKtTbyI=
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
github.com/puzpuzpuz/xsync/v3 v3.5.1 h1:GJYJZwO6IdxN/IKbneznS6yPkVC+c3zyY/j19c++5Fg=
github.com/puzpuzpuz/xsync/v3 v3.5.1/go.mod h1:VjzYrABPabuM4KyBh1Ftq6u8nhwY5tBPKP9jpmh0nnA=
github.com/redis/go-redis/v9 v9.17.1 h1:7tl732FjYPRT9H9aNfyTwKg9iTETjWjGKEJ2t/5iWTs=
github.com/redis/go-redis/v9 v9.17.1/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370=
github.com/redis/go-redis/v9 v9.17.2 h1:P2EGsA4qVIM3Pp+aPocCJ7DguDHhqrXNhVcEp4ViluI=
github.com/redis/go-redis/v9 v9.17.2/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
@@ -268,8 +252,6 @@ github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
github.com/rs/xid v1.4.0 h1:qd7wPTDkN6KQx2VmMBLrpHkiyQwgFXRnkOLacUiaSNY=
github.com/rs/xid v1.4.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4=
github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI=
github.com/shirou/gopsutil/v4 v4.25.6 h1:kLysI2JsKorfaFPcYmcJqbzROzsBWEOAtw6A7dIfqXs=
@@ -278,8 +260,6 @@ github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
@@ -310,11 +290,9 @@ github.com/testcontainers/testcontainers-go v0.40.0/go.mod h1:FSXV5KQtX2HAMlm7U3
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/match v1.2.0 h1:0pt8FlkOwjN2fPt4bIl4BoNxb98gGHN2ObFEDkrfZnM=
github.com/tidwall/match v1.2.0/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
@@ -344,8 +322,6 @@ github.com/warkanum/bun v1.2.17 h1:HP8eTuKSNcqMDhhIPFxEbgV/yct6RR0/c3qHH3PNZUA=
github.com/warkanum/bun v1.2.17/go.mod h1:jMoNg2n56ckaawi/O/J92BHaECmrz6IRjuMWqlMaMTM=
github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
github.com/xdg-go/scram v1.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY=
github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4=
github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs=
github.com/xdg-go/scram v1.2.0/go.mod h1:3dlrS0iBaWKYVt2ZfA4cj48umJZ+cAEbR6/SjLA88I8=
github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8=
@@ -381,16 +357,10 @@ go.opentelemetry.io/proto/otlp v1.7.1 h1:gTOMpGDb0WTBOP8JaO72iL3auEZhVmAQg4ipjOV
go.opentelemetry.io/proto/otlp v1.7.1/go.mod h1:b2rVh6rfI/s2pHWNlB7ILJcRALpcNDzKhACevjI+ZnE=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
@@ -407,12 +377,8 @@ golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOM
golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM=
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 h1:zfMcR1Cs4KNuomFFgGefv5N0czO2XZpUbxGUy8i8ug0=
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6/go.mod h1:46edojNIoXTNOhySWIWdix628clX9ODXwPsQuG6hsK0=
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 h1:fQsdNF2N+/YewlRZiricy4P1iimyPKZ/xwniHj8Q2a0=
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
@@ -421,8 +387,6 @@ golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
@@ -442,8 +406,6 @@ golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=
golang.org/x/net v0.45.0 h1:RLBg5JKixCy82FtLJpeNlVM0nrSqpCRYzVU1n8kj0tM=
golang.org/x/net v0.45.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -453,8 +415,6 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.9.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -480,8 +440,6 @@ golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
@@ -499,9 +457,8 @@ golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58=
golang.org/x/term v0.19.0/go.mod h1:2CuTdWZ7KHSQwUzKva0cbMg6q2DMI3Mmxp+gKJbskEk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0=
golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q=
golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss=
golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q=
golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg=
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=
@@ -516,8 +473,6 @@ golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4=
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
@@ -528,9 +483,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
@@ -541,8 +495,6 @@ google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5 h1:
google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5/go.mod h1:M4/wBTSeyLxupu3W3tJtOgB14jILAS/XWPSSa3TAlJc=
google.golang.org/grpc v1.75.0 h1:+TW+dqTd2Biwe6KKfhE5JpiYIBWq865PhKGSXiivqt4=
google.golang.org/grpc v1.75.0/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ=
google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
@@ -561,7 +513,6 @@ gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
gorm.io/driver/sqlserver v1.6.3 h1:UR+nWCuphPnq7UxnL57PSrlYjuvs+sf1N59GgFX7uAI=
gorm.io/driver/sqlserver v1.6.3/go.mod h1:VZeNn7hqX1aXoN5TPAFGWvxWG90xtA8erGn2gQmpc6U=
gorm.io/gorm v1.30.0 h1:qbT5aPv1UH8gI99OsRlvDToLxW5zR7FzS9acZDOZcgs=
gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE=
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
@@ -579,8 +530,6 @@ modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE=
modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.67.0 h1:QzL4IrKab2OFmxA3/vRYl0tLXrIamwrhD6CKD4WBVjQ=
modernc.org/libc v1.67.0/go.mod h1:QvvnnJ5P7aitu0ReNpVIEyesuhmDLQ8kaEoyMjIFZJA=
modernc.org/libc v1.67.4 h1:zZGmCMUVPORtKv95c2ReQN5VDjvkoRm9GWPTEPuvlWg=
modernc.org/libc v1.67.4/go.mod h1:QvvnnJ5P7aitu0ReNpVIEyesuhmDLQ8kaEoyMjIFZJA=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
@@ -591,8 +540,6 @@ modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/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.40.1 h1:VfuXcxcUWWKRBuP8+BR9L7VnmusMgBNNnBYGEe9w/iY=
modernc.org/sqlite v1.40.1/go.mod h1:9fjQZ0mB1LLP0GYrp39oOJXx/I2sxEnZtzCmEQIKvGE=
modernc.org/sqlite v1.42.2 h1:7hkZUNJvJFN2PgfUdjni9Kbvd4ef4mNLOu0B9FGxM74=
modernc.org/sqlite v1.42.2/go.mod h1:+VkC6v3pLOAE0A0uVucQEcbVW0I5nHCeDaBf+DpsQT8=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=

View File

@@ -211,6 +211,7 @@ type BunSelectQuery struct {
deferredPreloads []deferredPreload // Preloads to execute as separate queries
inJoinContext bool // Track if we're in a JOIN relation context
joinTableAlias string // Alias to use for JOIN conditions
skipAutoDetect bool // Skip auto-detection to prevent circular calls
}
// deferredPreload represents a preload that will be executed as a separate query
@@ -531,22 +532,25 @@ func (b *BunSelectQuery) Preload(relation string, conditions ...interface{}) com
func (b *BunSelectQuery) PreloadRelation(relation string, apply ...func(common.SelectQuery) common.SelectQuery) common.SelectQuery {
// Auto-detect relationship type and choose optimal loading strategy
// Get the model from the query if available
model := b.query.GetModel()
if model != nil && model.Value() != nil {
relType := reflection.GetRelationType(model.Value(), relation)
// Skip auto-detection if flag is set (prevents circular calls from JoinRelation)
if !b.skipAutoDetect {
model := b.query.GetModel()
if model != nil && model.Value() != nil {
relType := reflection.GetRelationType(model.Value(), relation)
// Log the detected relationship type
logger.Debug("PreloadRelation '%s' detected as: %s", relation, relType)
// Log the detected relationship type
logger.Debug("PreloadRelation '%s' detected as: %s", relation, relType)
// If this is a belongs-to or has-one relation, use JOIN for better performance
if relType.ShouldUseJoin() {
logger.Info("Using JOIN strategy for %s relation '%s'", relType, relation)
return b.JoinRelation(relation, apply...)
}
// If this is a belongs-to or has-one relation, use JOIN for better performance
if relType.ShouldUseJoin() {
logger.Info("Using JOIN strategy for %s relation '%s'", relType, relation)
return b.JoinRelation(relation, apply...)
}
// For has-many, many-to-many, or unknown: use separate query (safer default)
if relType == reflection.RelationHasMany || relType == reflection.RelationManyToMany {
logger.Debug("Using separate query for %s relation '%s'", relType, relation)
// For has-many, many-to-many, or unknown: use separate query (safer default)
if relType == reflection.RelationHasMany || relType == reflection.RelationManyToMany {
logger.Debug("Using separate query for %s relation '%s'", relType, relation)
}
}
}
@@ -559,7 +563,7 @@ func (b *BunSelectQuery) PreloadRelation(relation string, apply ...func(common.S
const safeAliasLimit = 35 // Leave room for column names
// If the alias chain is too long, defer this preload to be executed as a separate query
if len(aliasChain) > safeAliasLimit {
if len(relationParts) > 1 && len(aliasChain) > safeAliasLimit {
logger.Info("Preload relation '%s' creates long alias chain '%s' (%d chars). "+
"Using separate query to avoid PostgreSQL %d-char identifier limit.",
relation, aliasChain, len(aliasChain), postgresIdentifierLimit)
@@ -683,6 +687,10 @@ func (b *BunSelectQuery) JoinRelation(relation string, apply ...func(common.Sele
// Use PreloadRelation with the wrapped functions
// Bun's Relation() will use JOIN for belongs-to and has-one relations
// CRITICAL: Set skipAutoDetect flag to prevent circular call
// (PreloadRelation would detect belongs-to and call JoinRelation again)
b.skipAutoDetect = true
defer func() { b.skipAutoDetect = false }()
return b.PreloadRelation(relation, wrappedApply...)
}
@@ -742,6 +750,8 @@ func (b *BunSelectQuery) Scan(ctx context.Context, dest interface{}) (err error)
logger.Warn("Failed to execute deferred preloads: %v", err)
// Don't fail the whole query, just log the warning
}
// Clear deferred preloads to prevent re-execution
b.deferredPreloads = nil
}
return nil
@@ -810,6 +820,8 @@ func (b *BunSelectQuery) ScanModel(ctx context.Context) (err error) {
logger.Warn("Failed to execute deferred preloads: %v", err)
// Don't fail the whole query, just log the warning
}
// Clear deferred preloads to prevent re-execution
b.deferredPreloads = nil
}
return nil
@@ -898,13 +910,30 @@ func (b *BunSelectQuery) loadChildRelationForRecord(ctx context.Context, record
return nil
}
// Get the interface value to pass to Bun
parentValue := parentField.Interface()
// Get a pointer to the parent field so Bun can modify it
// CRITICAL: We need to pass a pointer, not a value, so that when Bun
// loads the child records and appends them to the slice, the changes
// are reflected in the original struct field.
var parentPtr interface{}
if parentField.Kind() == reflect.Ptr {
// Field is already a pointer (e.g., Parent *Parent), use as-is
parentPtr = parentField.Interface()
} else {
// Field is a value (e.g., Comments []Comment), get its address
if parentField.CanAddr() {
parentPtr = parentField.Addr().Interface()
} else {
return fmt.Errorf("cannot get address of field '%s'", parentRelation)
}
}
// Load the child relation on the parent record
// This uses a shorter alias since we're only loading "Child", not "Parent.Child"
// CRITICAL: Use WherePK() to ensure we only load children for THIS specific parent
// record, not the first parent in the database table.
return b.db.NewSelect().
Model(parentValue).
Model(parentPtr).
WherePK().
Relation(childRelation, func(sq *bun.SelectQuery) *bun.SelectQuery {
// Apply any custom query modifications
if len(apply) > 0 {

View File

@@ -74,6 +74,7 @@ func (p *NestedCUDProcessor) ProcessNestedCUD(
}
if modelType == nil || modelType.Kind() != reflect.Struct {
logger.Error("Invalid model type: operation=%s, table=%s, modelType=%v, expected struct", operation, tableName, modelType)
return nil, fmt.Errorf("model must be a struct type, got %v", modelType)
}
@@ -97,50 +98,74 @@ func (p *NestedCUDProcessor) ProcessNestedCUD(
}
}
// Filter regularData to only include fields that exist in the model
// Use MapToStruct to validate and filter fields
regularData = p.filterValidFields(regularData, model)
// Inject parent IDs for foreign key resolution
p.injectForeignKeys(regularData, modelType, parentIDs)
// Get the primary key name for this model
pkName := reflection.GetPrimaryKeyName(model)
// Check if we have any data to process (besides _request)
hasData := len(regularData) > 0
// Process based on operation
switch strings.ToLower(operation) {
case "insert", "create":
id, err := p.processInsert(ctx, regularData, tableName)
if err != nil {
return nil, fmt.Errorf("insert failed: %w", err)
}
result.ID = id
result.AffectedRows = 1
result.Data = regularData
// Only perform insert if we have data to insert
if hasData {
id, err := p.processInsert(ctx, regularData, 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)
}
result.ID = id
result.AffectedRows = 1
result.Data = regularData
// Process child relations after parent insert (to get parent ID)
if err := p.processChildRelations(ctx, "insert", id, relationFields, result.RelationData, modelType); err != nil {
return nil, fmt.Errorf("failed to process child relations: %w", err)
// Process child relations after parent insert (to get parent ID)
if err := p.processChildRelations(ctx, "insert", id, relationFields, result.RelationData, modelType, parentIDs); err != nil {
logger.Error("Failed to process child relations after insert: table=%s, parentID=%v, relations=%+v, error=%v", tableName, id, relationFields, err)
return nil, fmt.Errorf("failed to process child relations: %w", err)
}
} else {
logger.Debug("Skipping insert for %s - no data columns besides _request", tableName)
}
case "update":
rows, err := p.processUpdate(ctx, regularData, tableName, data[pkName])
if err != nil {
return nil, fmt.Errorf("update failed: %w", err)
}
result.ID = data[pkName]
result.AffectedRows = rows
result.Data = regularData
// Only perform update if we have data to update
if hasData {
rows, err := p.processUpdate(ctx, regularData, 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)
}
result.ID = data[pkName]
result.AffectedRows = rows
result.Data = regularData
// Process child relations for update
if err := p.processChildRelations(ctx, "update", data[pkName], relationFields, result.RelationData, modelType); err != nil {
return nil, fmt.Errorf("failed to process child relations: %w", err)
// Process child relations for update
if err := p.processChildRelations(ctx, "update", data[pkName], relationFields, result.RelationData, modelType, parentIDs); err != nil {
logger.Error("Failed to process child relations after update: table=%s, parentID=%v, relations=%+v, error=%v", tableName, data[pkName], relationFields, err)
return nil, fmt.Errorf("failed to process child relations: %w", err)
}
} else {
logger.Debug("Skipping update for %s - no data columns besides _request", tableName)
result.ID = data[pkName]
}
case "delete":
// Process child relations first (for referential integrity)
if err := p.processChildRelations(ctx, "delete", data[pkName], relationFields, result.RelationData, modelType); err != nil {
if err := p.processChildRelations(ctx, "delete", data[pkName], relationFields, result.RelationData, modelType, parentIDs); err != nil {
logger.Error("Failed to process child relations before delete: table=%s, id=%v, relations=%+v, error=%v", tableName, data[pkName], relationFields, err)
return nil, fmt.Errorf("failed to process child relations before delete: %w", err)
}
rows, err := p.processDelete(ctx, tableName, data[pkName])
if err != nil {
logger.Error("Delete failed for table=%s, id=%v, error=%v", tableName, data[pkName], err)
return nil, fmt.Errorf("delete failed: %w", err)
}
result.ID = data[pkName]
@@ -148,6 +173,7 @@ func (p *NestedCUDProcessor) ProcessNestedCUD(
result.Data = regularData
default:
logger.Error("Unsupported operation: %s for table=%s", operation, tableName)
return nil, fmt.Errorf("unsupported operation: %s", operation)
}
@@ -165,6 +191,115 @@ func (p *NestedCUDProcessor) extractCRUDRequest(data map[string]interface{}) str
return ""
}
// filterValidFields filters input data to only include fields that exist in the model
// Uses reflection.MapToStruct to validate fields and extract only those that match the model
func (p *NestedCUDProcessor) filterValidFields(data map[string]interface{}, model interface{}) map[string]interface{} {
if len(data) == 0 {
return data
}
// Create a new instance of the model to use with MapToStruct
modelType := reflect.TypeOf(model)
for modelType != nil && (modelType.Kind() == reflect.Ptr || modelType.Kind() == reflect.Slice || modelType.Kind() == reflect.Array) {
modelType = modelType.Elem()
}
if modelType == nil || modelType.Kind() != reflect.Struct {
return data
}
// Create a new instance of the model
tempModel := reflect.New(modelType).Interface()
// Use MapToStruct to map the data - this will only map valid fields
err := reflection.MapToStruct(data, tempModel)
if err != nil {
logger.Debug("Error mapping data to model: %v", err)
return data
}
// Extract the mapped fields back into a map
// This effectively filters out any fields that don't exist in the model
filteredData := make(map[string]interface{})
tempModelValue := reflect.ValueOf(tempModel).Elem()
for key, value := range data {
// Check if the field was successfully mapped
if fieldWasMapped(tempModelValue, modelType, key) {
filteredData[key] = value
} else {
logger.Debug("Skipping invalid field '%s' - not found in model %v", key, modelType)
}
}
return filteredData
}
// fieldWasMapped checks if a field with the given key was mapped to the model
func fieldWasMapped(modelValue reflect.Value, modelType reflect.Type, key string) bool {
// Look for the field by JSON tag or field name
for i := 0; i < modelType.NumField(); i++ {
field := modelType.Field(i)
// Skip unexported fields
if !field.IsExported() {
continue
}
// Check JSON tag
jsonTag := field.Tag.Get("json")
if jsonTag != "" && jsonTag != "-" {
parts := strings.Split(jsonTag, ",")
if len(parts) > 0 && parts[0] == key {
return true
}
}
// Check bun tag
bunTag := field.Tag.Get("bun")
if bunTag != "" && bunTag != "-" {
if colName := reflection.ExtractColumnFromBunTag(bunTag); colName == key {
return true
}
}
// Check gorm tag
gormTag := field.Tag.Get("gorm")
if gormTag != "" && gormTag != "-" {
if colName := reflection.ExtractColumnFromGormTag(gormTag); colName == key {
return true
}
}
// Check lowercase field name
if strings.EqualFold(field.Name, key) {
return true
}
// Handle embedded structs recursively
if field.Anonymous {
fieldType := field.Type
if fieldType.Kind() == reflect.Ptr {
fieldType = fieldType.Elem()
}
if fieldType.Kind() == reflect.Struct {
embeddedValue := modelValue.Field(i)
if embeddedValue.Kind() == reflect.Ptr {
if embeddedValue.IsNil() {
continue
}
embeddedValue = embeddedValue.Elem()
}
if fieldWasMapped(embeddedValue, fieldType, key) {
return true
}
}
}
}
return false
}
// injectForeignKeys injects parent IDs into data for foreign key fields
func (p *NestedCUDProcessor) injectForeignKeys(data map[string]interface{}, modelType reflect.Type, parentIDs map[string]interface{}) {
if len(parentIDs) == 0 {
@@ -213,6 +348,7 @@ func (p *NestedCUDProcessor) processInsert(
result, err := query.Exec(ctx)
if err != nil {
logger.Error("Insert execution failed: table=%s, data=%+v, error=%v", tableName, data, err)
return nil, fmt.Errorf("insert exec failed: %w", err)
}
@@ -236,6 +372,7 @@ func (p *NestedCUDProcessor) processUpdate(
id interface{},
) (int64, error) {
if id == nil {
logger.Error("Update requires an ID: table=%s, data=%+v", tableName, data)
return 0, fmt.Errorf("update requires an ID")
}
@@ -245,6 +382,7 @@ func (p *NestedCUDProcessor) processUpdate(
result, err := query.Exec(ctx)
if err != nil {
logger.Error("Update execution failed: table=%s, id=%v, data=%+v, error=%v", tableName, id, data, err)
return 0, fmt.Errorf("update exec failed: %w", err)
}
@@ -256,6 +394,7 @@ func (p *NestedCUDProcessor) processUpdate(
// processDelete handles delete operation
func (p *NestedCUDProcessor) processDelete(ctx context.Context, tableName string, id interface{}) (int64, error) {
if id == nil {
logger.Error("Delete requires an ID: table=%s", tableName)
return 0, fmt.Errorf("delete requires an ID")
}
@@ -265,6 +404,7 @@ func (p *NestedCUDProcessor) processDelete(ctx context.Context, tableName string
result, err := query.Exec(ctx)
if err != nil {
logger.Error("Delete execution failed: table=%s, id=%v, error=%v", tableName, id, err)
return 0, fmt.Errorf("delete exec failed: %w", err)
}
@@ -281,6 +421,7 @@ func (p *NestedCUDProcessor) processChildRelations(
relationFields map[string]*RelationshipInfo,
relationData map[string]interface{},
parentModelType reflect.Type,
incomingParentIDs map[string]interface{}, // IDs from all ancestors
) error {
for relationName, relInfo := range relationFields {
relationValue, exists := relationData[relationName]
@@ -293,7 +434,7 @@ func (p *NestedCUDProcessor) processChildRelations(
// Get the related model
field, found := parentModelType.FieldByName(relInfo.FieldName)
if !found {
logger.Warn("Field %s not found in model", relInfo.FieldName)
logger.Error("Field %s not found in model type %v for relation %s", relInfo.FieldName, parentModelType, relationName)
continue
}
@@ -313,20 +454,89 @@ func (p *NestedCUDProcessor) processChildRelations(
relatedTableName := p.getTableNameForModel(relatedModel, relInfo.JSONName)
// Prepare parent IDs for foreign key injection
// Start by copying all incoming parent IDs (from ancestors)
parentIDs := make(map[string]interface{})
if relInfo.ForeignKey != "" {
for k, v := range incomingParentIDs {
parentIDs[k] = v
}
logger.Debug("Inherited %d parent IDs from ancestors: %+v", len(incomingParentIDs), incomingParentIDs)
// Add the current parent's primary key to the parentIDs map
// This ensures nested children have access to all ancestor IDs
if parentID != nil && parentModelType != nil {
// Get the parent model's primary key field name
parentPKFieldName := reflection.GetPrimaryKeyName(parentModelType)
if parentPKFieldName != "" {
// Get the JSON name for the primary key field
parentPKJSONName := reflection.GetJSONNameForField(parentModelType, parentPKFieldName)
baseName := ""
if len(parentPKJSONName) > 1 {
baseName = parentPKJSONName
} else {
// Add parent's PK to the map using the base model name
baseName = strings.TrimSuffix(parentPKFieldName, "ID")
baseName = strings.TrimSuffix(strings.ToLower(baseName), "_id")
if baseName == "" {
baseName = "parent"
}
}
parentIDs[baseName] = parentID
logger.Debug("Added current parent PK to parentIDs map: %s=%v (from field %s)", baseName, parentID, parentPKFieldName)
}
}
// Also add the foreign key reference if specified
if relInfo.ForeignKey != "" && parentID != nil {
// Extract the base name from foreign key (e.g., "DepartmentID" -> "Department")
baseName := strings.TrimSuffix(relInfo.ForeignKey, "ID")
baseName = strings.TrimSuffix(strings.ToLower(baseName), "_id")
parentIDs[baseName] = parentID
// Only add if different from what we already added
if _, exists := parentIDs[baseName]; !exists {
parentIDs[baseName] = parentID
logger.Debug("Added foreign key to parentIDs map: %s=%v (from FK %s)", baseName, parentID, relInfo.ForeignKey)
}
}
logger.Debug("Final parentIDs map for relation %s: %+v", relationName, parentIDs)
// Determine which field name to use for setting parent ID in child data
// Priority: Use foreign key field name if specified
var foreignKeyFieldName string
if relInfo.ForeignKey != "" {
// Get the JSON name for the foreign key field in the child model
foreignKeyFieldName = reflection.GetJSONNameForField(relatedModelType, relInfo.ForeignKey)
if foreignKeyFieldName == "" {
// Fallback to lowercase field name
foreignKeyFieldName = strings.ToLower(relInfo.ForeignKey)
}
logger.Debug("Using foreign key field for direct assignment: %s (from FK %s)", foreignKeyFieldName, relInfo.ForeignKey)
}
// Get the primary key name for the child model to avoid overwriting it in recursive relationships
childPKName := reflection.GetPrimaryKeyName(relatedModel)
childPKFieldName := reflection.GetJSONNameForField(relatedModelType, childPKName)
if childPKFieldName == "" {
childPKFieldName = strings.ToLower(childPKName)
}
logger.Debug("Processing relation with foreignKeyField=%s, childPK=%s", foreignKeyFieldName, childPKFieldName)
// Process based on relation type and data structure
switch v := relationValue.(type) {
case map[string]interface{}:
// Single related object
// Single related object - directly set foreign key if specified
// IMPORTANT: In recursive relationships, don't overwrite the primary key
if parentID != nil && foreignKeyFieldName != "" && foreignKeyFieldName != childPKFieldName {
v[foreignKeyFieldName] = parentID
logger.Debug("Set foreign key in single relation: %s=%v", foreignKeyFieldName, parentID)
} else if foreignKeyFieldName == childPKFieldName {
logger.Debug("Skipping foreign key assignment - same as primary key (recursive relationship): %s", foreignKeyFieldName)
}
_, err := p.ProcessNestedCUD(ctx, operation, v, relatedModel, parentIDs, relatedTableName)
if err != nil {
logger.Error("Failed to process single relation: name=%s, table=%s, operation=%s, parentID=%v, data=%+v, error=%v",
relationName, relatedTableName, operation, parentID, v, err)
return fmt.Errorf("failed to process relation %s: %w", relationName, err)
}
@@ -334,24 +544,46 @@ func (p *NestedCUDProcessor) processChildRelations(
// Multiple related objects
for i, item := range v {
if itemMap, ok := item.(map[string]interface{}); ok {
// Directly set foreign key if specified
// IMPORTANT: In recursive relationships, don't overwrite the primary key
if parentID != nil && foreignKeyFieldName != "" && foreignKeyFieldName != childPKFieldName {
itemMap[foreignKeyFieldName] = parentID
logger.Debug("Set foreign key in relation array[%d]: %s=%v", i, foreignKeyFieldName, parentID)
} else if foreignKeyFieldName == childPKFieldName {
logger.Debug("Skipping foreign key assignment in array[%d] - same as primary key (recursive relationship): %s", i, foreignKeyFieldName)
}
_, err := p.ProcessNestedCUD(ctx, operation, itemMap, relatedModel, parentIDs, relatedTableName)
if err != nil {
logger.Error("Failed to process relation array item: name=%s[%d], table=%s, operation=%s, parentID=%v, data=%+v, error=%v",
relationName, i, relatedTableName, operation, parentID, itemMap, err)
return fmt.Errorf("failed to process relation %s[%d]: %w", relationName, i, err)
}
} else {
logger.Warn("Relation array item is not a map: name=%s[%d], type=%T", relationName, i, item)
}
}
case []map[string]interface{}:
// Multiple related objects (typed slice)
for i, itemMap := range v {
// Directly set foreign key if specified
// IMPORTANT: In recursive relationships, don't overwrite the primary key
if parentID != nil && foreignKeyFieldName != "" && foreignKeyFieldName != childPKFieldName {
itemMap[foreignKeyFieldName] = parentID
logger.Debug("Set foreign key in relation typed array[%d]: %s=%v", i, foreignKeyFieldName, parentID)
} else if foreignKeyFieldName == childPKFieldName {
logger.Debug("Skipping foreign key assignment in typed array[%d] - same as primary key (recursive relationship): %s", i, foreignKeyFieldName)
}
_, err := p.ProcessNestedCUD(ctx, operation, itemMap, relatedModel, parentIDs, relatedTableName)
if err != nil {
logger.Error("Failed to process relation typed array item: name=%s[%d], table=%s, operation=%s, parentID=%v, data=%+v, error=%v",
relationName, i, relatedTableName, operation, parentID, itemMap, err)
return fmt.Errorf("failed to process relation %s[%d]: %w", relationName, i, err)
}
}
default:
logger.Warn("Unsupported relation data type for %s: %T", relationName, relationValue)
logger.Error("Unsupported relation data type: name=%s, type=%T, value=%+v", relationName, relationValue, relationValue)
}
}

View File

@@ -0,0 +1,720 @@
package common
import (
"context"
"reflect"
"testing"
"github.com/bitechdev/ResolveSpec/pkg/reflection"
)
// Mock Database for testing
type mockDatabase struct {
insertCalls []map[string]interface{}
updateCalls []map[string]interface{}
deleteCalls []interface{}
lastID int64
}
func newMockDatabase() *mockDatabase {
return &mockDatabase{
insertCalls: make([]map[string]interface{}, 0),
updateCalls: make([]map[string]interface{}, 0),
deleteCalls: make([]interface{}, 0),
lastID: 1,
}
}
func (m *mockDatabase) NewSelect() SelectQuery { return &mockSelectQuery{} }
func (m *mockDatabase) NewInsert() InsertQuery { return &mockInsertQuery{db: m} }
func (m *mockDatabase) NewUpdate() UpdateQuery { return &mockUpdateQuery{db: m} }
func (m *mockDatabase) NewDelete() DeleteQuery { return &mockDeleteQuery{db: m} }
func (m *mockDatabase) RunInTransaction(ctx context.Context, fn func(Database) error) error {
return fn(m)
}
func (m *mockDatabase) Exec(ctx context.Context, query string, args ...interface{}) (Result, error) {
return &mockResult{rowsAffected: 1}, nil
}
func (m *mockDatabase) Query(ctx context.Context, dest interface{}, query string, args ...interface{}) error {
return nil
}
func (m *mockDatabase) BeginTx(ctx context.Context) (Database, error) {
return m, nil
}
func (m *mockDatabase) CommitTx(ctx context.Context) error {
return nil
}
func (m *mockDatabase) RollbackTx(ctx context.Context) error {
return nil
}
func (m *mockDatabase) GetUnderlyingDB() interface{} {
return nil
}
// Mock SelectQuery
type mockSelectQuery struct{}
func (m *mockSelectQuery) Model(model interface{}) SelectQuery { return m }
func (m *mockSelectQuery) Table(name string) SelectQuery { return m }
func (m *mockSelectQuery) Column(columns ...string) SelectQuery { return m }
func (m *mockSelectQuery) ColumnExpr(query string, args ...interface{}) SelectQuery { return m }
func (m *mockSelectQuery) Where(condition string, args ...interface{}) SelectQuery { return m }
func (m *mockSelectQuery) WhereOr(query string, args ...interface{}) SelectQuery { return m }
func (m *mockSelectQuery) Join(query string, args ...interface{}) SelectQuery { return m }
func (m *mockSelectQuery) LeftJoin(query string, args ...interface{}) SelectQuery { return m }
func (m *mockSelectQuery) Preload(relation string, conditions ...interface{}) SelectQuery { return m }
func (m *mockSelectQuery) PreloadRelation(relation string, apply ...func(SelectQuery) SelectQuery) SelectQuery { return m }
func (m *mockSelectQuery) JoinRelation(relation string, apply ...func(SelectQuery) SelectQuery) SelectQuery { return m }
func (m *mockSelectQuery) Order(order string) SelectQuery { return m }
func (m *mockSelectQuery) OrderExpr(order string, args ...interface{}) SelectQuery { return m }
func (m *mockSelectQuery) Limit(n int) SelectQuery { return m }
func (m *mockSelectQuery) Offset(n int) SelectQuery { return m }
func (m *mockSelectQuery) Group(group string) SelectQuery { return m }
func (m *mockSelectQuery) Having(condition string, args ...interface{}) SelectQuery { return m }
func (m *mockSelectQuery) Scan(ctx context.Context, dest interface{}) error { return nil }
func (m *mockSelectQuery) ScanModel(ctx context.Context) error { return nil }
func (m *mockSelectQuery) Count(ctx context.Context) (int, error) { return 0, nil }
func (m *mockSelectQuery) Exists(ctx context.Context) (bool, error) { return false, nil }
// Mock InsertQuery
type mockInsertQuery struct {
db *mockDatabase
table string
values map[string]interface{}
}
func (m *mockInsertQuery) Model(model interface{}) InsertQuery { return m }
func (m *mockInsertQuery) Table(name string) InsertQuery {
m.table = name
return m
}
func (m *mockInsertQuery) Value(column string, value interface{}) InsertQuery {
if m.values == nil {
m.values = make(map[string]interface{})
}
m.values[column] = value
return m
}
func (m *mockInsertQuery) OnConflict(action string) InsertQuery { return m }
func (m *mockInsertQuery) Returning(columns ...string) InsertQuery { return m }
func (m *mockInsertQuery) Exec(ctx context.Context) (Result, error) {
// Record the insert call
m.db.insertCalls = append(m.db.insertCalls, m.values)
m.db.lastID++
return &mockResult{lastID: m.db.lastID, rowsAffected: 1}, nil
}
// Mock UpdateQuery
type mockUpdateQuery struct {
db *mockDatabase
table string
setValues map[string]interface{}
}
func (m *mockUpdateQuery) Model(model interface{}) UpdateQuery { return m }
func (m *mockUpdateQuery) Table(name string) UpdateQuery {
m.table = name
return m
}
func (m *mockUpdateQuery) Set(column string, value interface{}) UpdateQuery { return m }
func (m *mockUpdateQuery) SetMap(values map[string]interface{}) UpdateQuery {
m.setValues = values
return m
}
func (m *mockUpdateQuery) Where(condition string, args ...interface{}) 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
m.db.updateCalls = append(m.db.updateCalls, m.setValues)
return &mockResult{rowsAffected: 1}, nil
}
// Mock DeleteQuery
type mockDeleteQuery struct {
db *mockDatabase
table string
}
func (m *mockDeleteQuery) Model(model interface{}) DeleteQuery { return m }
func (m *mockDeleteQuery) Table(name string) DeleteQuery {
m.table = name
return m
}
func (m *mockDeleteQuery) Where(condition string, args ...interface{}) DeleteQuery { return m }
func (m *mockDeleteQuery) Exec(ctx context.Context) (Result, error) {
// Record the delete call
m.db.deleteCalls = append(m.db.deleteCalls, m.table)
return &mockResult{rowsAffected: 1}, nil
}
// Mock Result
type mockResult struct {
lastID int64
rowsAffected int64
}
func (m *mockResult) LastInsertId() (int64, error) { return m.lastID, nil }
func (m *mockResult) RowsAffected() int64 { return m.rowsAffected }
// Mock ModelRegistry
type mockModelRegistry struct{}
func (m *mockModelRegistry) GetModel(name string) (interface{}, error) { return nil, nil }
func (m *mockModelRegistry) GetModelByEntity(schema, entity string) (interface{}, error) { return nil, nil }
func (m *mockModelRegistry) RegisterModel(name string, model interface{}) error { return nil }
func (m *mockModelRegistry) GetAllModels() map[string]interface{} { return make(map[string]interface{}) }
// Mock RelationshipInfoProvider
type mockRelationshipProvider struct {
relationships map[string]*RelationshipInfo
}
func newMockRelationshipProvider() *mockRelationshipProvider {
return &mockRelationshipProvider{
relationships: make(map[string]*RelationshipInfo),
}
}
func (m *mockRelationshipProvider) GetRelationshipInfo(modelType reflect.Type, relationName string) *RelationshipInfo {
key := modelType.Name() + "." + relationName
return m.relationships[key]
}
func (m *mockRelationshipProvider) RegisterRelation(modelTypeName, relationName string, info *RelationshipInfo) {
key := modelTypeName + "." + relationName
m.relationships[key] = info
}
// Test Models
type Department struct {
ID int64 `json:"id" bun:"id,pk"`
Name string `json:"name"`
Employees []*Employee `json:"employees,omitempty"`
}
func (d Department) TableName() string { return "departments" }
func (d Department) GetIDName() string { return "ID" }
type Employee struct {
ID int64 `json:"id" bun:"id,pk"`
Name string `json:"name"`
DepartmentID int64 `json:"department_id"`
Tasks []*Task `json:"tasks,omitempty"`
}
func (e Employee) TableName() string { return "employees" }
func (e Employee) GetIDName() string { return "ID" }
type Task struct {
ID int64 `json:"id" bun:"id,pk"`
Title string `json:"title"`
EmployeeID int64 `json:"employee_id"`
Comments []*Comment `json:"comments,omitempty"`
}
func (t Task) TableName() string { return "tasks" }
func (t Task) GetIDName() string { return "ID" }
type Comment struct {
ID int64 `json:"id" bun:"id,pk"`
Text string `json:"text"`
TaskID int64 `json:"task_id"`
}
func (c Comment) TableName() string { return "comments" }
func (c Comment) GetIDName() string { return "ID" }
// Test Cases
func TestProcessNestedCUD_SingleLevelInsert(t *testing.T) {
db := newMockDatabase()
registry := &mockModelRegistry{}
relProvider := newMockRelationshipProvider()
// Register Department -> Employees relationship
relProvider.RegisterRelation("Department", "employees", &RelationshipInfo{
FieldName: "Employees",
JSONName: "employees",
RelationType: "has_many",
ForeignKey: "DepartmentID",
RelatedModel: Employee{},
})
processor := NewNestedCUDProcessor(db, registry, relProvider)
data := map[string]interface{}{
"name": "Engineering",
"employees": []interface{}{
map[string]interface{}{
"name": "John Doe",
},
map[string]interface{}{
"name": "Jane Smith",
},
},
}
result, err := processor.ProcessNestedCUD(
context.Background(),
"insert",
data,
Department{},
nil,
"departments",
)
if err != nil {
t.Fatalf("ProcessNestedCUD failed: %v", err)
}
if result.ID == nil {
t.Error("Expected result.ID to be set")
}
// Verify department was inserted
if len(db.insertCalls) != 3 {
t.Errorf("Expected 3 insert calls (1 dept + 2 employees), got %d", len(db.insertCalls))
}
// Verify first insert is department
if db.insertCalls[0]["name"] != "Engineering" {
t.Errorf("Expected department name 'Engineering', got %v", db.insertCalls[0]["name"])
}
// Verify employees were inserted with foreign key
if db.insertCalls[1]["department_id"] == nil {
t.Error("Expected employee to have department_id set")
}
if db.insertCalls[2]["department_id"] == nil {
t.Error("Expected employee to have department_id set")
}
}
func TestProcessNestedCUD_MultiLevelInsert(t *testing.T) {
db := newMockDatabase()
registry := &mockModelRegistry{}
relProvider := newMockRelationshipProvider()
// Register relationships
relProvider.RegisterRelation("Department", "employees", &RelationshipInfo{
FieldName: "Employees",
JSONName: "employees",
RelationType: "has_many",
ForeignKey: "DepartmentID",
RelatedModel: Employee{},
})
relProvider.RegisterRelation("Employee", "tasks", &RelationshipInfo{
FieldName: "Tasks",
JSONName: "tasks",
RelationType: "has_many",
ForeignKey: "EmployeeID",
RelatedModel: Task{},
})
processor := NewNestedCUDProcessor(db, registry, relProvider)
data := map[string]interface{}{
"name": "Engineering",
"employees": []interface{}{
map[string]interface{}{
"name": "John Doe",
"tasks": []interface{}{
map[string]interface{}{
"title": "Task 1",
},
map[string]interface{}{
"title": "Task 2",
},
},
},
},
}
result, err := processor.ProcessNestedCUD(
context.Background(),
"insert",
data,
Department{},
nil,
"departments",
)
if err != nil {
t.Fatalf("ProcessNestedCUD failed: %v", err)
}
if result.ID == nil {
t.Error("Expected result.ID to be set")
}
// Verify: 1 dept + 1 employee + 2 tasks = 4 inserts
if len(db.insertCalls) != 4 {
t.Errorf("Expected 4 insert calls, got %d", len(db.insertCalls))
}
// Verify department
if db.insertCalls[0]["name"] != "Engineering" {
t.Errorf("Expected department name 'Engineering', got %v", db.insertCalls[0]["name"])
}
// Verify employee has department_id
if db.insertCalls[1]["department_id"] == nil {
t.Error("Expected employee to have department_id set")
}
// Verify tasks have employee_id
if db.insertCalls[2]["employee_id"] == nil {
t.Error("Expected task to have employee_id set")
}
if db.insertCalls[3]["employee_id"] == nil {
t.Error("Expected task to have employee_id set")
}
}
func TestProcessNestedCUD_RequestFieldOverride(t *testing.T) {
db := newMockDatabase()
registry := &mockModelRegistry{}
relProvider := newMockRelationshipProvider()
relProvider.RegisterRelation("Department", "employees", &RelationshipInfo{
FieldName: "Employees",
JSONName: "employees",
RelationType: "has_many",
ForeignKey: "DepartmentID",
RelatedModel: Employee{},
})
processor := NewNestedCUDProcessor(db, registry, relProvider)
data := map[string]interface{}{
"name": "Engineering",
"employees": []interface{}{
map[string]interface{}{
"_request": "update",
"ID": int64(10), // Use capital ID to match struct field
"name": "John Updated",
},
},
}
_, err := processor.ProcessNestedCUD(
context.Background(),
"insert",
data,
Department{},
nil,
"departments",
)
if err != nil {
t.Fatalf("ProcessNestedCUD failed: %v", err)
}
// Verify department was inserted (1 insert)
// Employee should be updated (1 update)
if len(db.insertCalls) != 1 {
t.Errorf("Expected 1 insert call for department, got %d", len(db.insertCalls))
}
if len(db.updateCalls) != 1 {
t.Errorf("Expected 1 update call for employee, got %d", len(db.updateCalls))
}
// Verify update data
if db.updateCalls[0]["name"] != "John Updated" {
t.Errorf("Expected employee name 'John Updated', got %v", db.updateCalls[0]["name"])
}
}
func TestProcessNestedCUD_SkipInsertWhenOnlyRequestField(t *testing.T) {
db := newMockDatabase()
registry := &mockModelRegistry{}
relProvider := newMockRelationshipProvider()
relProvider.RegisterRelation("Department", "employees", &RelationshipInfo{
FieldName: "Employees",
JSONName: "employees",
RelationType: "has_many",
ForeignKey: "DepartmentID",
RelatedModel: Employee{},
})
processor := NewNestedCUDProcessor(db, registry, relProvider)
// Data with only _request field for nested employee
data := map[string]interface{}{
"name": "Engineering",
"employees": []interface{}{
map[string]interface{}{
"_request": "insert",
// No other fields besides _request
// Note: Foreign key will be injected, so employee WILL be inserted
},
},
}
_, err := processor.ProcessNestedCUD(
context.Background(),
"insert",
data,
Department{},
nil,
"departments",
)
if err != nil {
t.Fatalf("ProcessNestedCUD failed: %v", err)
}
// Department + Employee (with injected FK) = 2 inserts
if len(db.insertCalls) != 2 {
t.Errorf("Expected 2 insert calls (department + employee with FK), got %d", len(db.insertCalls))
}
if db.insertCalls[0]["name"] != "Engineering" {
t.Errorf("Expected department name 'Engineering', got %v", db.insertCalls[0]["name"])
}
// Verify employee has foreign key
if db.insertCalls[1]["department_id"] == nil {
t.Error("Expected employee to have department_id injected")
}
}
func TestProcessNestedCUD_Update(t *testing.T) {
db := newMockDatabase()
registry := &mockModelRegistry{}
relProvider := newMockRelationshipProvider()
relProvider.RegisterRelation("Department", "employees", &RelationshipInfo{
FieldName: "Employees",
JSONName: "employees",
RelationType: "has_many",
ForeignKey: "DepartmentID",
RelatedModel: Employee{},
})
processor := NewNestedCUDProcessor(db, registry, relProvider)
data := map[string]interface{}{
"ID": int64(1), // Use capital ID to match struct field
"name": "Engineering Updated",
"employees": []interface{}{
map[string]interface{}{
"_request": "insert",
"name": "New Employee",
},
},
}
result, err := processor.ProcessNestedCUD(
context.Background(),
"update",
data,
Department{},
nil,
"departments",
)
if err != nil {
t.Fatalf("ProcessNestedCUD failed: %v", err)
}
if result.ID != int64(1) {
t.Errorf("Expected result.ID to be 1, got %v", result.ID)
}
// Verify department was updated
if len(db.updateCalls) != 1 {
t.Errorf("Expected 1 update call, got %d", len(db.updateCalls))
}
// Verify new employee was inserted
if len(db.insertCalls) != 1 {
t.Errorf("Expected 1 insert call for new employee, got %d", len(db.insertCalls))
}
}
func TestProcessNestedCUD_Delete(t *testing.T) {
db := newMockDatabase()
registry := &mockModelRegistry{}
relProvider := newMockRelationshipProvider()
relProvider.RegisterRelation("Department", "employees", &RelationshipInfo{
FieldName: "Employees",
JSONName: "employees",
RelationType: "has_many",
ForeignKey: "DepartmentID",
RelatedModel: Employee{},
})
processor := NewNestedCUDProcessor(db, registry, relProvider)
data := map[string]interface{}{
"ID": int64(1), // Use capital ID to match struct field
"employees": []interface{}{
map[string]interface{}{
"_request": "delete",
"ID": int64(10), // Use capital ID
},
map[string]interface{}{
"_request": "delete",
"ID": int64(11), // Use capital ID
},
},
}
_, err := processor.ProcessNestedCUD(
context.Background(),
"delete",
data,
Department{},
nil,
"departments",
)
if err != nil {
t.Fatalf("ProcessNestedCUD failed: %v", err)
}
// Verify employees were deleted first, then department
// 2 employees + 1 department = 3 deletes
if len(db.deleteCalls) != 3 {
t.Errorf("Expected 3 delete calls, got %d", len(db.deleteCalls))
}
}
func TestProcessNestedCUD_ParentIDPropagation(t *testing.T) {
db := newMockDatabase()
registry := &mockModelRegistry{}
relProvider := newMockRelationshipProvider()
// Register 3-level relationships
relProvider.RegisterRelation("Department", "employees", &RelationshipInfo{
FieldName: "Employees",
JSONName: "employees",
RelationType: "has_many",
ForeignKey: "DepartmentID",
RelatedModel: Employee{},
})
relProvider.RegisterRelation("Employee", "tasks", &RelationshipInfo{
FieldName: "Tasks",
JSONName: "tasks",
RelationType: "has_many",
ForeignKey: "EmployeeID",
RelatedModel: Task{},
})
relProvider.RegisterRelation("Task", "comments", &RelationshipInfo{
FieldName: "Comments",
JSONName: "comments",
RelationType: "has_many",
ForeignKey: "TaskID",
RelatedModel: Comment{},
})
processor := NewNestedCUDProcessor(db, registry, relProvider)
data := map[string]interface{}{
"name": "Engineering",
"employees": []interface{}{
map[string]interface{}{
"name": "John",
"tasks": []interface{}{
map[string]interface{}{
"title": "Task 1",
"comments": []interface{}{
map[string]interface{}{
"text": "Great work!",
},
},
},
},
},
},
}
_, err := processor.ProcessNestedCUD(
context.Background(),
"insert",
data,
Department{},
nil,
"departments",
)
if err != nil {
t.Fatalf("ProcessNestedCUD failed: %v", err)
}
// Verify: 1 dept + 1 employee + 1 task + 1 comment = 4 inserts
if len(db.insertCalls) != 4 {
t.Errorf("Expected 4 insert calls, got %d", len(db.insertCalls))
}
// Verify department
if db.insertCalls[0]["name"] != "Engineering" {
t.Error("Expected department to be inserted first")
}
// Verify employee has department_id
if db.insertCalls[1]["department_id"] == nil {
t.Error("Expected employee to have department_id")
}
// Verify task has employee_id
if db.insertCalls[2]["employee_id"] == nil {
t.Error("Expected task to have employee_id")
}
// Verify comment has task_id
if db.insertCalls[3]["task_id"] == nil {
t.Error("Expected comment to have task_id")
}
}
func TestInjectForeignKeys(t *testing.T) {
db := newMockDatabase()
registry := &mockModelRegistry{}
relProvider := newMockRelationshipProvider()
processor := NewNestedCUDProcessor(db, registry, relProvider)
data := map[string]interface{}{
"name": "John",
}
parentIDs := map[string]interface{}{
"department": int64(5),
}
modelType := reflect.TypeOf(Employee{})
processor.injectForeignKeys(data, modelType, parentIDs)
// Should inject department_id based on the "department" key in parentIDs
if data["department_id"] == nil {
t.Error("Expected department_id to be injected")
}
if data["department_id"] != int64(5) {
t.Errorf("Expected department_id to be 5, got %v", data["department_id"])
}
}
func TestGetPrimaryKeyName(t *testing.T) {
dept := Department{}
pkName := reflection.GetPrimaryKeyName(dept)
if pkName != "ID" {
t.Errorf("Expected primary key name 'ID', got '%s'", pkName)
}
// Test with pointer
pkName2 := reflection.GetPrimaryKeyName(&dept)
if pkName2 != "ID" {
t.Errorf("Expected primary key name 'ID' from pointer, got '%s'", pkName2)
}
}

View File

@@ -128,7 +128,7 @@ func DefaultManagerConfig() ManagerConfig {
RetryAttempts: 3,
RetryDelay: 1 * time.Second,
RetryMaxDelay: 10 * time.Second,
HealthCheckInterval: 30 * time.Second,
HealthCheckInterval: 15 * time.Second,
EnableAutoReconnect: true,
}
}
@@ -161,6 +161,11 @@ func (c *ManagerConfig) ApplyDefaults() {
if c.HealthCheckInterval == 0 {
c.HealthCheckInterval = defaults.HealthCheckInterval
}
// EnableAutoReconnect defaults to true - apply if not explicitly set
// Since this is a boolean, we apply the default unconditionally when it's false
if !c.EnableAutoReconnect {
c.EnableAutoReconnect = defaults.EnableAutoReconnect
}
}
// Validate validates the manager configuration

View File

@@ -219,9 +219,10 @@ func (m *connectionManager) Connect(ctx context.Context) error {
logger.Info("Database connection established: name=%s, type=%s", name, connCfg.Type)
}
// Start background health checks if enabled
if m.config.EnableAutoReconnect && m.config.HealthCheckInterval > 0 {
// Always start background health checks
if m.config.HealthCheckInterval > 0 {
m.startHealthChecker()
logger.Info("Background health checker started: interval=%v", m.config.HealthCheckInterval)
}
logger.Info("Database manager initialized: connections=%d", len(m.connections))

View File

@@ -0,0 +1,226 @@
package dbmanager
import (
"context"
"database/sql"
"testing"
"time"
_ "github.com/mattn/go-sqlite3"
)
func TestBackgroundHealthChecker(t *testing.T) {
// Create a SQLite in-memory database
db, err := sql.Open("sqlite3", ":memory:")
if err != nil {
t.Fatalf("Failed to open database: %v", err)
}
defer db.Close()
// Create manager config with a short health check interval for testing
cfg := ManagerConfig{
DefaultConnection: "test",
Connections: map[string]ConnectionConfig{
"test": {
Name: "test",
Type: DatabaseTypeSQLite,
FilePath: ":memory:",
},
},
HealthCheckInterval: 1 * time.Second, // Short interval for testing
EnableAutoReconnect: true,
}
// Create manager
mgr, err := NewManager(cfg)
if err != nil {
t.Fatalf("Failed to create manager: %v", err)
}
// Connect - this should start the background health checker
ctx := context.Background()
err = mgr.Connect(ctx)
if err != nil {
t.Fatalf("Failed to connect: %v", err)
}
defer mgr.Close()
// Get the connection to verify it's healthy
conn, err := mgr.Get("test")
if err != nil {
t.Fatalf("Failed to get connection: %v", err)
}
// Verify initial health check
err = conn.HealthCheck(ctx)
if err != nil {
t.Errorf("Initial health check failed: %v", err)
}
// Wait for a few health check cycles
time.Sleep(3 * time.Second)
// Get stats to verify the connection is still healthy
stats := conn.Stats()
if stats == nil {
t.Fatal("Expected stats to be returned")
}
if !stats.Connected {
t.Error("Expected connection to still be connected")
}
if stats.HealthCheckStatus == "" {
t.Error("Expected health check status to be set")
}
// Verify the manager has started the health checker
if cm, ok := mgr.(*connectionManager); ok {
if cm.healthTicker == nil {
t.Error("Expected health ticker to be running")
}
}
}
func TestDefaultHealthCheckInterval(t *testing.T) {
// Verify the default health check interval is 15 seconds
defaults := DefaultManagerConfig()
expectedInterval := 15 * time.Second
if defaults.HealthCheckInterval != expectedInterval {
t.Errorf("Expected default health check interval to be %v, got %v",
expectedInterval, defaults.HealthCheckInterval)
}
if !defaults.EnableAutoReconnect {
t.Error("Expected EnableAutoReconnect to be true by default")
}
}
func TestApplyDefaultsEnablesAutoReconnect(t *testing.T) {
// Create a config without setting EnableAutoReconnect
cfg := ManagerConfig{
Connections: map[string]ConnectionConfig{
"test": {
Name: "test",
Type: DatabaseTypeSQLite,
FilePath: ":memory:",
},
},
}
// Verify it's false initially (Go's zero value for bool)
if cfg.EnableAutoReconnect {
t.Error("Expected EnableAutoReconnect to be false before ApplyDefaults")
}
// Apply defaults
cfg.ApplyDefaults()
// Verify it's now true
if !cfg.EnableAutoReconnect {
t.Error("Expected EnableAutoReconnect to be true after ApplyDefaults")
}
// Verify health check interval is also set
if cfg.HealthCheckInterval != 15*time.Second {
t.Errorf("Expected health check interval to be 15s, got %v", cfg.HealthCheckInterval)
}
}
func TestManagerHealthCheck(t *testing.T) {
// Create a SQLite in-memory database
db, err := sql.Open("sqlite3", ":memory:")
if err != nil {
t.Fatalf("Failed to open database: %v", err)
}
defer db.Close()
// Create manager config
cfg := ManagerConfig{
DefaultConnection: "test",
Connections: map[string]ConnectionConfig{
"test": {
Name: "test",
Type: DatabaseTypeSQLite,
FilePath: ":memory:",
},
},
HealthCheckInterval: 15 * time.Second,
EnableAutoReconnect: true,
}
// Create and connect manager
mgr, err := NewManager(cfg)
if err != nil {
t.Fatalf("Failed to create manager: %v", err)
}
ctx := context.Background()
err = mgr.Connect(ctx)
if err != nil {
t.Fatalf("Failed to connect: %v", err)
}
defer mgr.Close()
// Perform health check on all connections
err = mgr.HealthCheck(ctx)
if err != nil {
t.Errorf("Health check failed: %v", err)
}
// Get stats
stats := mgr.Stats()
if stats == nil {
t.Fatal("Expected stats to be returned")
}
if stats.TotalConnections != 1 {
t.Errorf("Expected 1 total connection, got %d", stats.TotalConnections)
}
if stats.HealthyCount != 1 {
t.Errorf("Expected 1 healthy connection, got %d", stats.HealthyCount)
}
if stats.UnhealthyCount != 0 {
t.Errorf("Expected 0 unhealthy connections, got %d", stats.UnhealthyCount)
}
}
func TestManagerStatsAfterClose(t *testing.T) {
cfg := ManagerConfig{
DefaultConnection: "test",
Connections: map[string]ConnectionConfig{
"test": {
Name: "test",
Type: DatabaseTypeSQLite,
FilePath: ":memory:",
},
},
HealthCheckInterval: 15 * time.Second,
}
mgr, err := NewManager(cfg)
if err != nil {
t.Fatalf("Failed to create manager: %v", err)
}
ctx := context.Background()
err = mgr.Connect(ctx)
if err != nil {
t.Fatalf("Failed to connect: %v", err)
}
// Close the manager
err = mgr.Close()
if err != nil {
t.Errorf("Failed to close manager: %v", err)
}
// Stats should show no connections
stats := mgr.Stats()
if stats.TotalConnections != 0 {
t.Errorf("Expected 0 total connections after close, got %d", stats.TotalConnections)
}
}

View File

@@ -1,6 +1,9 @@
package reflection
import "reflect"
import (
"reflect"
"strings"
)
func Len(v any) int {
val := reflect.ValueOf(v)
@@ -64,3 +67,41 @@ func GetPointerElement(v reflect.Type) reflect.Type {
}
return v
}
// GetJSONNameForField gets the JSON tag name for a struct field.
// Returns the JSON field name from the json struct tag, or an empty string if not found.
// Handles the "json" tag format: "name", "name,omitempty", etc.
func GetJSONNameForField(modelType reflect.Type, fieldName string) string {
if modelType == nil {
return ""
}
// Handle pointer types
if modelType.Kind() == reflect.Ptr {
modelType = modelType.Elem()
}
if modelType.Kind() != reflect.Struct {
return ""
}
// Find the field
field, found := modelType.FieldByName(fieldName)
if !found {
return ""
}
// Get the JSON tag
jsonTag := field.Tag.Get("json")
if jsonTag == "" {
return ""
}
// Parse the tag (format: "name,omitempty" or just "name")
parts := strings.Split(jsonTag, ",")
if len(parts) > 0 && parts[0] != "" && parts[0] != "-" {
return parts[0]
}
return ""
}

View File

@@ -1370,6 +1370,63 @@ func convertToFloat64(value interface{}) (float64, bool) {
return 0, false
}
// GetValidJSONFieldNames returns a map of valid JSON field names for a model
// This can be used to validate input data against a model's structure
// The map keys are the JSON field names (from json tags) that exist in the model
func GetValidJSONFieldNames(modelType reflect.Type) map[string]bool {
validFields := make(map[string]bool)
// Unwrap pointers to get to the base struct type
for modelType != nil && modelType.Kind() == reflect.Pointer {
modelType = modelType.Elem()
}
if modelType == nil || modelType.Kind() != reflect.Struct {
return validFields
}
collectValidFieldNames(modelType, validFields)
return validFields
}
// collectValidFieldNames recursively collects valid JSON field names from a struct type
func collectValidFieldNames(typ reflect.Type, validFields map[string]bool) {
for i := 0; i < typ.NumField(); i++ {
field := typ.Field(i)
// Skip unexported fields
if !field.IsExported() {
continue
}
// Check for embedded structs
if field.Anonymous {
fieldType := field.Type
if fieldType.Kind() == reflect.Ptr {
fieldType = fieldType.Elem()
}
if fieldType.Kind() == reflect.Struct {
// Recursively add fields from embedded struct
collectValidFieldNames(fieldType, validFields)
continue
}
}
// Get the JSON tag name for this field (same logic as MapToStruct)
jsonTag := field.Tag.Get("json")
if jsonTag != "" && jsonTag != "-" {
// Extract the field name from the JSON tag (before any options like omitempty)
parts := strings.Split(jsonTag, ",")
if len(parts) > 0 && parts[0] != "" {
validFields[parts[0]] = true
}
} else {
// If no JSON tag, use the field name in lowercase as a fallback
validFields[strings.ToLower(field.Name)] = true
}
}
}
// getRelationModelSingleLevel gets the model type for a single level field (non-recursive)
// This is a helper function used by GetRelationModel to handle one level at a time
func getRelationModelSingleLevel(model interface{}, fieldName string) interface{} {

View File

@@ -883,7 +883,7 @@ func (h *Handler) applyPreloadWithRecursion(query common.SelectQuery, preload co
})
// Handle recursive preloading
if preload.Recursive && depth < 5 {
if preload.Recursive && depth < 4 {
logger.Debug("Applying recursive preload for %s at depth %d", preload.Relation, depth+1)
// For recursive relationships, we need to get the last part of the relation path
@@ -1794,10 +1794,46 @@ func (h *Handler) processChildRelationsForField(
parentIDs[baseName] = parentID
}
// Determine which field name to use for setting parent ID in child data
// Priority: Use foreign key field name if specified, otherwise use parent's PK name
var foreignKeyFieldName string
if relInfo.ForeignKey != "" {
// Get the JSON name for the foreign key field in the child model
foreignKeyFieldName = reflection.GetJSONNameForField(relatedModelType, relInfo.ForeignKey)
if foreignKeyFieldName == "" {
// Fallback to lowercase field name
foreignKeyFieldName = strings.ToLower(relInfo.ForeignKey)
}
} else {
// Fallback: use parent's primary key name
parentPKName := reflection.GetPrimaryKeyName(parentModelType)
foreignKeyFieldName = reflection.GetJSONNameForField(parentModelType, parentPKName)
if foreignKeyFieldName == "" {
foreignKeyFieldName = strings.ToLower(parentPKName)
}
}
// Get the primary key name for the child model to avoid overwriting it in recursive relationships
childPKName := reflection.GetPrimaryKeyName(relatedModel)
childPKFieldName := reflection.GetJSONNameForField(relatedModelType, childPKName)
if childPKFieldName == "" {
childPKFieldName = strings.ToLower(childPKName)
}
logger.Debug("Setting parent ID in child data: foreignKeyField=%s, parentID=%v, relForeignKey=%s, childPK=%s",
foreignKeyFieldName, parentID, relInfo.ForeignKey, childPKFieldName)
// Process based on relation type and data structure
switch v := relationValue.(type) {
case map[string]interface{}:
// Single related object
// Single related object - add parent ID to foreign key field
// IMPORTANT: In recursive relationships, don't overwrite the primary key
if parentID != nil && foreignKeyFieldName != "" && foreignKeyFieldName != childPKFieldName {
v[foreignKeyFieldName] = parentID
logger.Debug("Set foreign key in single relation: %s=%v", foreignKeyFieldName, parentID)
} else if foreignKeyFieldName == childPKFieldName {
logger.Debug("Skipping foreign key assignment - same as primary key (recursive relationship): %s", foreignKeyFieldName)
}
_, err := processor.ProcessNestedCUD(ctx, operation, v, relatedModel, parentIDs, relatedTableName)
if err != nil {
return fmt.Errorf("failed to process single relation: %w", err)
@@ -1807,6 +1843,14 @@ func (h *Handler) processChildRelationsForField(
// Multiple related objects
for i, item := range v {
if itemMap, ok := item.(map[string]interface{}); ok {
// Add parent ID to foreign key field
// IMPORTANT: In recursive relationships, don't overwrite the primary key
if parentID != nil && foreignKeyFieldName != "" && foreignKeyFieldName != childPKFieldName {
itemMap[foreignKeyFieldName] = parentID
logger.Debug("Set foreign key in relation array[%d]: %s=%v", i, foreignKeyFieldName, parentID)
} else if foreignKeyFieldName == childPKFieldName {
logger.Debug("Skipping foreign key assignment in array[%d] - same as primary key (recursive relationship): %s", i, foreignKeyFieldName)
}
_, err := processor.ProcessNestedCUD(ctx, operation, itemMap, relatedModel, parentIDs, relatedTableName)
if err != nil {
return fmt.Errorf("failed to process relation item %d: %w", i, err)
@@ -1817,6 +1861,14 @@ func (h *Handler) processChildRelationsForField(
case []map[string]interface{}:
// Multiple related objects (typed slice)
for i, itemMap := range v {
// Add parent ID to foreign key field
// IMPORTANT: In recursive relationships, don't overwrite the primary key
if parentID != nil && foreignKeyFieldName != "" && foreignKeyFieldName != childPKFieldName {
itemMap[foreignKeyFieldName] = parentID
logger.Debug("Set foreign key in relation typed array[%d]: %s=%v", i, foreignKeyFieldName, parentID)
} else if foreignKeyFieldName == childPKFieldName {
logger.Debug("Skipping foreign key assignment in typed array[%d] - same as primary key (recursive relationship): %s", i, foreignKeyFieldName)
}
_, err := processor.ProcessNestedCUD(ctx, operation, itemMap, relatedModel, parentIDs, relatedTableName)
if err != nil {
return fmt.Errorf("failed to process relation item %d: %w", i, err)