Compare commits

...

529 Commits

Author SHA1 Message Date
Mikescher e12764c0a2 v0.0.640 add error return parameter to GinWrapper.ListenAndServeHTTP
Build Docker and Deploy / Run goext test-suite (push) Successful in 1m30s
2026-05-12 10:54:15 +02:00
Mikescher 53aa8c05b0 v0.0.639 remove langext.Ptr from templates
Build Docker and Deploy / Run goext test-suite (push) Successful in 1m52s
2026-05-08 10:28:40 +02:00
Mikescher 4f96907758 v0.0.638 remove go:fix inline from ArrPtr
Build Docker and Deploy / Run goext test-suite (push) Successful in 2m41s
2026-05-08 10:10:54 +02:00
Mikescher b3131e3ba6 v0.0.637 allow calling coll.Paginat() with limit=0 (to get only counts)
Build Docker and Deploy / Run goext test-suite (push) Successful in 2m32s
2026-05-06 16:11:01 +02:00
Mikescher 02d6894ec6 [🤖] Add Unit-Tests
Build Docker and Deploy / Run goext test-suite (push) Successful in 1m34s
2026-04-27 16:31:29 +02:00
Mikescher dad0e3240d v0.0.636 Remove remaining traces of v1 mongo driver
Build Docker and Deploy / Run goext test-suite (push) Successful in 1m36s
2026-04-26 14:53:24 +02:00
Mikescher a5cece2bc7 v0.0.635 mongo driver v2 (Viktor)
Build Docker and Deploy / Run goext test-suite (push) Successful in 1m41s
2026-04-26 14:47:05 +02:00
Mikescher cdce955887 Merge remote-tracking branch 'origin/feature/mongo-driver-v2'
Build Docker and Deploy / Run goext test-suite (push) Successful in 1m34s
2026-04-26 14:42:53 +02:00
Mikescher 90862ed3f5 Merge branch 'feature/upgrade-go'
Build Docker and Deploy / Run goext test-suite (push) Successful in 1m35s
2026-04-26 14:31:48 +02:00
Mikescher 18c172d69a Revert gojson changes
Build Docker and Deploy / Run goext test-suite (push) Successful in 1m34s
2026-04-26 14:29:28 +02:00
Mikescher 80cea13437 v0.0.634 add ReplyTo to googleapi.SendMail
Build Docker and Deploy / Run goext test-suite (push) Successful in 1m37s
2026-04-24 13:38:00 +02:00
viktor f9576a2fec go mod tidy
Build Docker and Deploy / Run goext test-suite (push) Successful in 1m39s
2026-04-21 18:45:52 +02:00
viktor 26d542c9a2 added mongo-driver v2
Build Docker and Deploy / Run goext test-suite (push) Failing after 1m33s
2026-04-21 18:41:32 +02:00
viktor d30e778bd4 fixed test
Build Docker and Deploy / Run goext test-suite (push) Successful in 1m35s
2026-04-21 16:54:52 +02:00
viktor 73e867f75a fixed test to properly wait for goroutine completion 2026-04-21 16:54:28 +02:00
viktor 84b87d61f2 updated dependencies and go 2026-04-21 11:06:01 +02:00
Mikescher f62e7499ec v0.0.633
Build Docker and Deploy / Run goext test-suite (push) Successful in 1m42s
2026-04-13 16:12:09 +02:00
Mikescher 1edc2712ed v0.0.632 Pubsub.PublishAsync
Build Docker and Deploy / Run goext test-suite (push) Successful in 1m41s
2026-04-13 16:08:28 +02:00
Mikescher 63c28b4141 v0.0.631
Build Docker and Deploy / Run goext test-suite (push) Successful in 2m0s
2026-03-14 15:14:38 +01:00
Mikescher b01e659bb4 v0.0.630
Build Docker and Deploy / Run goext test-suite (push) Failing after 1m24s
2026-03-14 14:10:09 +01:00
Mikescher 0923fa7c09 v0.0.629 excelext
Build Docker and Deploy / Run goext test-suite (push) Has been cancelled
2026-03-14 14:09:13 +01:00
Mikescher e19cb30713 v0.0.628
Build Docker and Deploy / Run goext test-suite (push) Successful in 4m37s
2026-03-14 01:13:37 +01:00
Mikescher 90dc6079d5 v0.0.627
Build Docker and Deploy / Run goext test-suite (push) Has been cancelled
2026-03-14 01:13:07 +01:00
Mikescher 5b5c262994 v0.0.626 wsw
Build Docker and Deploy / Run goext test-suite (push) Successful in 1m40s
2026-03-14 00:50:15 +01:00
Mikescher 44ec5e4804 v0.0.625
Build Docker and Deploy / Run goext test-suite (push) Failing after 1m31s
2026-03-11 11:43:26 +01:00
TimoV 2054c04442 added test for StrRemoveControlCharacters func
Build Docker and Deploy / Run goext test-suite (push) Successful in 1m39s
2026-03-10 19:58:04 +01:00
TimoV 373d28d405 added remove control characters func
Build Docker and Deploy / Run goext test-suite (push) Successful in 1m37s
2026-03-10 19:54:28 +01:00
Mikescher 8aaf2cd257 v0.0.623
Build Docker and Deploy / Run goext test-suite (push) Successful in 1m46s
2026-02-03 11:33:03 +01:00
Mikescher a373876d32 Remove redundant failure-mail step from pipeline (now handled by gitea itself)
Build Docker and Deploy / Run goext test-suite (push) Successful in 1m40s
2026-01-23 10:16:30 +01:00
Mikescher 12324ba60d v0.0.622 fix tests
Build Docker and Deploy / Run goext test-suite (push) Successful in 1m38s
2026-01-09 07:01:41 +01:00
Mikescher a032d09ea2 v0.0.621
Build Docker and Deploy / Run goext test-suite (push) Failing after 1m34s
2026-01-08 23:04:46 +01:00
Mikescher 4bebc9ea38 v0.0.620
Build Docker and Deploy / Run goext test-suite (push) Has been cancelled
2026-01-08 23:03:55 +01:00
Mikescher 580906ea08 v0.0.619 better handle error in ginext.JSON() with invalid data
Build Docker and Deploy / Run goext test-suite (push) Failing after 1m26s
2026-01-08 22:58:50 +01:00
Mikescher 0f28e3d11b v0.0.618 remove deprecated param 'user_id' from scn.send()
Build Docker and Deploy / Run goext test-suite (push) Failing after 1m46s
2025-12-06 22:20:02 +01:00
Mikescher 55f80432bb v0.0.617
Build Docker and Deploy / Run goext test-suite (push) Successful in 2m18s
2025-12-06 11:54:36 +01:00
Mikescher d6daf0e285 v0.0.616
Build Docker and Deploy / Run goext test-suite (push) Has been cancelled
2025-12-06 11:53:59 +01:00
Mikescher 1c2bc060da v0.0.615 MultiMutex
Build Docker and Deploy / Run goext test-suite (push) Successful in 1m55s
2025-12-06 11:50:54 +01:00
Mikescher 34023dca4c v0.0.614 ArrCount
Build Docker and Deploy / Run goext test-suite (push) Successful in 2m26s
2025-11-29 12:49:17 +01:00
Mikescher 69f2dd73c5 fix tests
Build Docker and Deploy / Run goext test-suite (push) Successful in 2m23s
2025-11-06 09:54:28 +01:00
christopher dce6f634b1 v0.0.613 Add function to convert RFC3339Time To RFC3339NanoTime. Add function to shuffle array
Build Docker and Deploy / Run goext test-suite (push) Failing after 1m37s
2025-11-05 14:48:04 +01:00
christopher 833f74427f Add ToNano method to convert RFC3339Time to RFC3339NanoTime 2025-11-05 14:45:47 +01:00
christopher 535a699584 Add ArrShuffle function to shuffle array elements 2025-11-05 14:45:38 +01:00
Mikescher b439b95f83 v0.0.612 fix sender-name in scn package
Build Docker and Deploy / Run goext test-suite (push) Failing after 1m33s
2025-11-02 23:14:31 +01:00
Mikescher 995a82d90a Update googleapi/README.md
Build Docker and Deploy / Run goext test-suite (push) Failing after 1m43s
2025-10-22 11:39:01 +02:00
Mikescher 0db10845ed v0.0.611 add dataext.MutexSet
Build Docker and Deploy / Run goext test-suite (push) Failing after 1m45s
2025-10-15 09:55:14 +02:00
Mikescher 128ca25aa2 v0.0.610
Build Docker and Deploy / Run goext test-suite (push) Successful in 2m17s
2025-10-04 00:29:57 +02:00
Mikescher f5d13ebe64 v0.0.609
Build Docker and Deploy / Run goext test-suite (push) Has been cancelled
2025-10-04 00:29:09 +02:00
Mikescher 9730a91ad5 v0.0.608
Build Docker and Deploy / Run goext test-suite (push) Has been cancelled
2025-10-04 00:27:41 +02:00
Mikescher 8c16e4d982 v0.0.607
Build Docker and Deploy / Run goext test-suite (push) Successful in 2m17s
2025-10-04 00:24:13 +02:00
Mikescher 039a53a395 v0.0.606 add .DataMeta() to enums
Build Docker and Deploy / Run goext test-suite (push) Successful in 2m18s
2025-10-04 00:08:32 +02:00
Mikescher 2cf571579b v0.0.605 do not panic in GoJSONRenderer
Build Docker and Deploy / Run goext test-suite (push) Successful in 2m16s
2025-09-29 16:39:16 +02:00
Mikescher 9a537bb8c2 v0.0.604
Build Docker and Deploy / Run goext test-suite (push) Successful in 3m16s
2025-09-20 15:21:15 +02:00
Mikescher 78ad103151 v0.0.603
Build Docker and Deploy / Run goext test-suite (push) Has been cancelled
2025-09-20 15:19:09 +02:00
Mikescher c764a946ff v0.0.602 add listener to DelayedCombiningInvoker
Build Docker and Deploy / Run goext test-suite (push) Successful in 2m18s
2025-09-20 15:13:02 +02:00
Mikescher ef59b1241f Fix tests
Build Docker and Deploy / Run goext test-suite (push) Successful in 2m14s
2025-09-13 20:41:37 +02:00
Mikescher a70ab33559 v0.0.601 Add Wait and Update method to Atomic[T]
Build Docker and Deploy / Run goext test-suite (push) Failing after 1m34s
2025-09-13 19:04:52 +02:00
Mikescher a58bb4b14b v0.0.600
Build Docker and Deploy / Run goext test-suite (push) Failing after 2m30s
2025-09-13 18:45:23 +02:00
Mikescher dc62bbe55f v0.0.599 implement dataext.broadcaster
Build Docker and Deploy / Run goext test-suite (push) Failing after 1m37s
2025-09-13 18:42:17 +02:00
Mikescher b832d77d3e v0.0.598 prevent json marshalling of PassHash
Build Docker and Deploy / Run goext test-suite (push) Failing after 2m35s
2025-09-11 11:17:34 +02:00
Mikescher 38467cb4e7 v0.0.597 add update methods to SyncMap
Build Docker and Deploy / Run goext test-suite (push) Successful in 3m47s
2025-09-04 14:25:06 +02:00
TimoV 68b06158b3 v0.0.596 force json map
Build Docker and Deploy / Run goext test-suite (push) Successful in 3m42s
2025-08-26 15:55:57 +02:00
Mikescher 5f51173276 v0.0.595 fix zerlog channel for exerr [ZeroLogErrTraces] output and WRN errors
Build Docker and Deploy / Run goext test-suite (push) Successful in 3m33s
2025-08-20 13:03:21 +02:00
Mikescher 1586314e3e v0.0.594 Add exerr OutputRaw(http.ResponseWriter) method
Build Docker and Deploy / Run goext test-suite (push) Successful in 2m35s
2025-07-16 17:13:07 +02:00
Mikescher 254fe1556a v0.0.593 made PubSub more generic (namespace can be any comparable type)
Build Docker and Deploy / Run goext test-suite (push) Successful in 2m54s
2025-07-16 12:50:36 +02:00
Mikescher 52e74b59f5 v0.0.592
Build Docker and Deploy / Run goext test-suite (push) Successful in 3m30s
2025-07-16 12:46:18 +02:00
Mikescher 64f2cd7219 v0.0.591 implement namespaced PubSub Broker in dataext
Build Docker and Deploy / Run goext test-suite (push) Has been cancelled
2025-07-16 12:44:55 +02:00
Mikescher a29aec8fb5 v0.0.590 more rfctime equal fixes for chris
Build Docker and Deploy / Run goext test-suite (push) Successful in 2m34s
2025-07-15 14:14:22 +02:00
Mikescher 8ea9b3f79f v0.0.589 improve Equal method of rfctime structs - prevents panic in cmp library
Build Docker and Deploy / Run goext test-suite (push) Failing after 1m48s
2025-07-15 13:46:36 +02:00
Mikescher a4b2a0589f v0.0.588
Build Docker and Deploy / Run goext test-suite (push) Successful in 2m37s
2025-07-11 11:50:29 +02:00
Mikescher 4ef5f6059b v0.0.587
Build Docker and Deploy / Run goext test-suite (push) Successful in 2m37s
2025-07-06 22:24:44 +02:00
Mikescher b23a444aa2 v0.0.586
Build Docker and Deploy / Run goext test-suite (push) Successful in 2m33s
2025-07-04 13:56:53 +02:00
Mikescher 09932046f8 v0.0.585
Build Docker and Deploy / Run goext test-suite (push) Successful in 2m36s
2025-07-04 11:46:00 +02:00
Mikescher 37e52595a2 v0.0.584
Build Docker and Deploy / Run goext test-suite (push) Successful in 2m33s
2025-06-26 16:48:07 +02:00
Mikescher 95d7c90492 v0.0.583
Build Docker and Deploy / Run goext test-suite (push) Successful in 2m31s
2025-06-25 10:59:23 +02:00
Mikescher 23a3235c7e v0.0.582
Build Docker and Deploy / Run goext test-suite (push) Successful in 2m33s
2025-06-25 10:51:38 +02:00
Mikescher 506d276962 v0.0.581
Build Docker and Deploy / Run goext test-suite (push) Successful in 3m39s
2025-06-25 10:28:54 +02:00
Mikescher 2a0cf84416 v0.0.580 Add IsZero() to generated ID types
Build Docker and Deploy / Run goext test-suite (push) Successful in 2m33s
2025-06-16 08:40:07 +02:00
Mikescher 073aa84dd4 v0.0.579 fix StackSkip count on exerr zero-logger for Build()
Build Docker and Deploy / Run goext test-suite (push) Successful in 2m29s
2025-06-13 16:53:47 +02:00
Mikescher a0dc9e92e4 v0.0.578
Build Docker and Deploy / Run goext test-suite (push) Successful in 2m30s
2025-06-11 14:37:51 +02:00
Mikescher 98c591b019 v0.0.577
Build Docker and Deploy / Run goext test-suite (push) Successful in 3m7s
2025-05-18 21:42:18 +02:00
Mikescher a93b93a3cd v0.0.576
Build Docker and Deploy / Run goext test-suite (push) Successful in 3m26s
2025-05-18 14:26:24 +02:00
Mikescher 49bc52d63e v0.0.575 DelayedCombiningInvoker
Build Docker and Deploy / Run goext test-suite (push) Successful in 3m31s
2025-05-11 19:17:05 +02:00
Mikescher 959020e3c0 v0.0.574 add syncMap.clear()
Build Docker and Deploy / Run goext test-suite (push) Successful in 3m37s
2025-05-07 15:28:15 +02:00
TimoV 395e83acf6 panic bf url
Build Docker and Deploy / Run goext test-suite (push) Successful in 3m36s
2025-05-06 19:17:49 +02:00
Mikescher 55ff89f179 v0.0.572 switch to git.blackforestbytes.com as module name
Build Docker and Deploy / Run goext test-suite (push) Has been cancelled
2025-05-03 16:43:59 +02:00
Mikescher cbaa283f74 v0.0.571 add AsAnyPtr() function to ids
Build Docker and Deploy / Run goext test-suite (push) Successful in 2m45s
2025-04-30 21:06:24 +02:00
Mikescher 20fb1f5601 v0.0.570 add gin_host exerr metadata (for gin-auto-fields)
Build Docker and Deploy / Run goext test-suite (push) Successful in 2m35s
2025-04-25 23:20:25 +02:00
Mikescher cc58639306 v0.0.569
Build Docker and Deploy / Run goext test-suite (push) Successful in 2m30s
2025-04-07 15:47:50 +02:00
Mikescher cea822ffa6 v0.0.568 remove duplicate ids in ExErr.UniqueIDs
Build Docker and Deploy / Run goext test-suite (push) Successful in 2m38s
2025-03-15 22:29:45 +01:00
Mikescher c20ae20cc1 v0.0.567 Add ListenerOpt to exerr.RegisterListener (this is a breking API change !! -- but will prevent more breakage later on...)
Build Docker and Deploy / Run goext test-suite (push) Successful in 2m33s
2025-03-06 12:19:03 +01:00
Mikescher f07cd79b96 v0.0.566
Build Docker and Deploy / Run goext test-suite (push) Successful in 2m35s
2025-02-28 21:46:26 +01:00
Mikescher 164c462b96 v0.0.565
Build Docker and Deploy / Run goext test-suite (push) Successful in 2m33s
2025-02-28 21:43:36 +01:00
Mikescher 5e6cb63f14 v0.0.564 always return non-nil ctx from ginext.Start() (improves nilaway)
Build Docker and Deploy / Run goext test-suite (push) Successful in 2m7s
2025-02-10 13:04:05 +01:00
Mikescher 4832aa9d6c v0.0.563 Add 'ArrContains' alias for 'InArray'
Build Docker and Deploy / Run goext test-suite (push) Successful in 2m7s
2025-01-31 21:16:42 +01:00
Mikescher 4d606d3131 v0.0.562 bf
Build Docker and Deploy / Run goext test-suite (push) Successful in 2m4s
2025-01-29 11:24:20 +01:00
Mikescher be9b9e8ccf v0.0.561 wmo PaginateIterateFunc+PaginateIterate
Build Docker and Deploy / Run goext test-suite (push) Failing after 1m31s
2025-01-29 11:02:41 +01:00
Mikescher 28cdfc5bd2 v0.0.560 wmo ListIterateFunc + ListIterate
Build Docker and Deploy / Run goext test-suite (push) Failing after 1m31s
2025-01-29 10:54:53 +01:00
Mikescher 10a6627323 v0.0.559 Add .Iterate and .IterateFunc methods to wmo
Build Docker and Deploy / Run goext test-suite (push) Failing after 1m33s
2025-01-28 15:55:18 +01:00
Mikescher 06b3b4116e v0.0.558 update gojson (rebase onto go1.23.4)
Build Docker and Deploy / Run goext test-suite (push) Successful in 2m19s
2025-01-10 15:37:04 +01:00
Mikescher ff821390f7 Apply goext specific patches to gojson
Build Docker and Deploy / Run goext test-suite (push) Has been cancelled
2025-01-10 15:35:14 +01:00
Mikescher c8e9c34706 Reset gojson to golang/go|1.23.4 [removes all custom changes] 2025-01-10 11:49:29 +01:00
Mikescher b7c48cb467 v0.0.556
Build Docker and Deploy / Run goext test-suite (push) Successful in 3m29s
2025-01-09 10:41:00 +01:00
Mikescher a0a80899f5 v0.0.555
Build Docker and Deploy / Run goext test-suite (push) Has been cancelled
2025-01-09 10:39:56 +01:00
Mikescher 3543441b96 v0.0.554
Build Docker and Deploy / Run goext test-suite (push) Has been cancelled
2025-01-09 10:39:31 +01:00
Mikescher eef12da4e6 v0.0.553
Build Docker and Deploy / Run goext test-suite (push) Successful in 3m24s
2025-01-09 10:29:22 +01:00
Mikescher d009aafd4e v0.0.552 mathext.ClampOpt
Build Docker and Deploy / Run goext test-suite (push) Successful in 2m50s
2025-01-05 03:40:15 +01:00
Mikescher f7b4aa48d7 v0.0.551 change exerr.RecursiveMessage() logic: use messages of Wrap() if not empty
Build Docker and Deploy / Run goext test-suite (push) Successful in 2m9s
2025-01-04 02:33:49 +01:00
Mikescher 36b092774d v0.0.550 ArrMapSum
Build Docker and Deploy / Run goext test-suite (push) Successful in 2m12s
2024-12-26 00:23:24 +01:00
Mikescher a8c6e39ac5 v0.0.549
Build Docker and Deploy / Run goext test-suite (push) Successful in 2m4s
2024-12-10 13:24:06 +01:00
Mikescher 62f2ce9268 v0.0.548
Build Docker and Deploy / Run goext test-suite (push) Successful in 2m56s
2024-12-09 17:39:35 +01:00
Mikescher 49375e90f0 v0.0.547 allow calling ListWithCount with pageSize=0
Build Docker and Deploy / Run goext test-suite (push) Successful in 2m55s
2024-12-08 18:04:04 +01:00
Mikescher d8cf255c80 v0.0.546 Fix ginext json-parse error when the bufferedReader was read beforehand
Build Docker and Deploy / Run goext test-suite (push) Successful in 2m55s
2024-11-28 12:06:57 +01:00
Mikescher b520282ba0 v0.0.545
Build Docker and Deploy / Run goext test-suite (push) Successful in 2m6s
2024-11-27 13:21:45 +01:00
Mikescher 27cc9366b5 v0.0.544
Build Docker and Deploy / Run goext test-suite (push) Successful in 3m0s
2024-11-26 15:10:27 +01:00
Mikescher d9517fe73c v0.0.543
Build Docker and Deploy / Run goext test-suite (push) Successful in 2m7s
2024-11-13 15:03:51 +01:00
Mikescher 8a92a6cc52 v0.0.542
Build Docker and Deploy / Run goext test-suite (push) Successful in 2m9s
2024-11-07 13:13:12 +01:00
Mikescher 9b2028ab54 v0.0.541
Build Docker and Deploy / Run goext test-suite (push) Successful in 3m3s
2024-11-05 14:38:42 +01:00
Mikescher 207fd331d5 v0.0.540 handle ct=nil same as ct=Start
Build Docker and Deploy / Run goext test-suite (push) Successful in 2m25s
2024-10-28 14:35:05 +01:00
Mikescher 54b0d6701d v0.0.539
Build Docker and Deploy / Run goext test-suite (push) Successful in 2m49s
2024-10-27 02:21:35 +02:00
Mikescher fc2657179b v0.0.538
Build Docker and Deploy / Run goext test-suite (push) Successful in 2m7s
2024-10-27 02:18:45 +02:00
Mikescher d4894e31fe Merge branch 'cursortoken-paginated'
Build Docker and Deploy / Run goext test-suite (push) Has been cancelled
2024-10-27 02:18:25 +02:00
Mikescher 0ddfaf666b v0.0.537 fix goext error print always showing error-type of highest-level error
Build Docker and Deploy / Run goext test-suite (push) Successful in 2m12s
2024-10-26 23:38:13 +02:00
Mikescher e154137105 Trying out paginated cursortoken variant [UNTESTED]
Build Docker and Deploy / Run goext test-suite (push) Successful in 2m11s
2024-10-25 09:45:42 +02:00
Mikescher 9b9a79b4ad v0.0.536 revert bfcodegen changes
Build Docker and Deploy / Run goext test-suite (push) Successful in 2m24s
2024-10-22 09:57:06 +02:00
Mikescher 5a8d7110e4 v0.0.535
Build Docker and Deploy / Run goext test-suite (push) Successful in 2m52s
2024-10-22 09:41:59 +02:00
Mikescher d47c84cd47 v0.0.534
Build Docker and Deploy / Run goext test-suite (push) Has been cancelled
2024-10-22 09:40:53 +02:00
Mikescher c571f3f888 v0.0.533 Made String() functions in bfcodegen nil-ptr safe
Build Docker and Deploy / Run goext test-suite (push) Successful in 2m9s
2024-10-22 09:36:40 +02:00
Mikescher e884ba6b89 v0.0.532
Build Docker and Deploy / Run goext test-suite (push) Successful in 2m53s
2024-10-13 16:33:39 +02:00
Mikescher 1a8e31e5ef v0.0.531
Build Docker and Deploy / Run goext test-suite (push) Successful in 2m58s
2024-10-13 16:10:55 +02:00
Mikescher eccc0fe9e5 v0.0.530
Build Docker and Deploy / Run goext test-suite (push) Successful in 3m31s
2024-10-09 11:15:26 +02:00
Mikescher c8dec24a0d v0.0.529 handle PanicWrappedErr in exerr.FromError()
Build Docker and Deploy / Run goext test-suite (push) Successful in 3m13s
2024-10-08 19:22:17 +02:00
Mikescher b8cb989e54 v0.0.528
Build Docker and Deploy / Run goext test-suite (push) Successful in 4m38s
2024-10-07 17:20:40 +02:00
Mikescher ec672fbd49 v0.0.527
Build Docker and Deploy / Run goext test-suite (push) Has been cancelled
2024-10-07 17:19:30 +02:00
Mikescher cfb0b53fc7 v0.0.526
Build Docker and Deploy / Run goext test-suite (push) Successful in 3m17s
2024-10-05 23:59:23 +02:00
Mikescher a7389f44fa v0.0.525 upgrad go1.22 -> go1.23
Build Docker and Deploy / Run goext test-suite (push) Successful in 3m4s
2024-10-05 02:45:20 +02:00
Mikescher 69f0fedd66 v0.0.524
Build Docker and Deploy / Run goext test-suite (push) Failing after 1m22s
2024-10-05 01:41:10 +02:00
Mikescher 335ef4d8e8 v0.0.523 ringbuffer
Build Docker and Deploy / Run goext test-suite (push) Failing after 1m23s
2024-10-05 01:28:46 +02:00
Mikescher 61801ff20d v0.0.522
Build Docker and Deploy / Run goext test-suite (push) Successful in 2m53s
2024-10-05 01:12:00 +02:00
Mikescher 361dca5c85 v0.0.521 ctxext
Build Docker and Deploy / Run goext test-suite (push) Successful in 2m56s
2024-10-05 01:06:36 +02:00
Mikescher 9f85a243e8 v0.0.520
Build Docker and Deploy / Run goext test-suite (push) Successful in 4m7s
2024-10-05 01:02:25 +02:00
Mikescher dc6cb274ee v0.0.519
Build Docker and Deploy / Run goext test-suite (push) Has been cancelled
2024-10-05 00:58:15 +02:00
Mikescher f6b47792a4 v0.0.518 Improve sq db-listener interface (breaking)
Build Docker and Deploy / Run goext test-suite (push) Successful in 4m11s
2024-10-05 00:45:55 +02:00
Mikescher 295b3ef793 v0.0.517 add constructor funcs for tuples
Build Docker and Deploy / Run goext test-suite (push) Successful in 4m21s
2024-10-02 11:31:34 +02:00
Mikescher 721c176337 v0.0.516
Build Docker and Deploy / Run goext test-suite (push) Successful in 4m25s
2024-09-25 21:43:41 +02:00
Mikescher ebba6545a3 v0.0.515
Build Docker and Deploy / Run goext test-suite (push) Successful in 5m35s
2024-09-16 17:39:51 +02:00
Mikescher 19c7e22ced v0.0.514 fix mongo filter where the primary sort key is null in db (fallback to secondary)
Build Docker and Deploy / Run goext test-suite (push) Has been cancelled
2024-09-16 17:39:18 +02:00
Mikescher 9f883b458f v0.0.513
Build Docker and Deploy / Run goext test-suite (push) Successful in 4m55s
2024-09-16 15:27:32 +02:00
Mikescher 1f456c5134 v0.0.512
Build Docker and Deploy / Run goext test-suite (push) Successful in 5m6s
2024-09-15 21:25:21 +02:00
Mikescher d7fbef37db v0.0.511
Build Docker and Deploy / Run goext test-suite (push) Successful in 3m10s
2024-09-15 18:22:07 +02:00
Mikescher a1668b6e5a v0.0.510
Build Docker and Deploy / Run goext test-suite (push) Successful in 4m24s
2024-09-13 18:06:49 +02:00
Mikescher 3a17edfaf0 v0.0.509
Build Docker and Deploy / Run goext test-suite (push) Successful in 6m2s
2024-08-26 14:35:49 +02:00
Mikescher 3320a9c19d v0.0.508
Build Docker and Deploy / Run goext test-suite (push) Successful in 4m25s
2024-08-25 17:36:20 +02:00
Mikescher 8dcd8a270a v0.0.507 fix jsonfilter:"-" not working
Build Docker and Deploy / Run goext test-suite (push) Successful in 7m7s
2024-08-25 15:41:17 +02:00
Mikescher 03a9b276d8 v0.0.506 allow empty-string as value for enum
Build Docker and Deploy / Run goext test-suite (push) Failing after 7m36s
2024-08-22 11:45:02 +02:00
Mikescher 9c8cde384f v0.0.505
Build Docker and Deploy / Run goext test-suite (push) Successful in 6m17s
2024-08-08 15:57:05 +02:00
Mikescher 99b000ecf4 v0.0.504
Build Docker and Deploy / Run goext test-suite (push) Successful in 3m53s
2024-08-07 19:44:45 +02:00
Mikescher a173e30090 v0.0.503
Build Docker and Deploy / Run goext test-suite (push) Successful in 3m52s
2024-08-07 19:37:38 +02:00
Mikescher a3481a7d2d v0.0.502
Build Docker and Deploy / Run goext test-suite (push) Has been cancelled
2024-08-07 19:35:23 +02:00
Mikescher a8e6f98a89 v0.0.501
Build Docker and Deploy / Run goext test-suite (push) Has been cancelled
2024-08-07 19:31:36 +02:00
Mikescher ab805403b9 v0.0.500
Build Docker and Deploy / Run goext test-suite (push) Has been cancelled
2024-08-07 19:30:38 +02:00
Mikescher 1e98d351ce v0.0.499
Build Docker and Deploy / Run goext test-suite (push) Successful in 5m24s
2024-08-07 18:34:22 +02:00
Mikescher c40bdc8e9e v0.0.498
Build Docker and Deploy / Run goext test-suite (push) Successful in 4m13s
2024-08-07 17:26:35 +02:00
Mikescher 7204562879 v0.0.497
Build Docker and Deploy / Run goext test-suite (push) Failing after 2m33s
2024-08-07 17:04:59 +02:00
Mikescher 741611a2e1 v0.0.496 wpdf fixes and wpdf test.go
Build Docker and Deploy / Run goext test-suite (push) Successful in 4m58s
2024-08-07 15:34:06 +02:00
Mikescher 133aeb8374 v0.0.495
Build Docker and Deploy / Run goext test-suite (push) Successful in 3m44s
2024-08-07 14:00:02 +02:00
Mikescher b78a468632 v0.0.494 add tables to wpdf
Build Docker and Deploy / Run goext test-suite (push) Has been cancelled
2024-08-07 13:57:29 +02:00
Mikescher f1b4480e0f v0.0.493 fix panic in RegisterImage for very short images
Build Docker and Deploy / Run goext test-suite (push) Successful in 5m22s
2024-08-07 09:22:37 +02:00
Mikescher ffffe4bf24 v0.0.492
Build Docker and Deploy / Run goext test-suite (push) Successful in 5m32s
2024-08-02 16:19:21 +02:00
Mikescher 413bf3c848 v0.0.491 small optimization in Paginate method
Build Docker and Deploy / Run goext test-suite (push) Failing after 8m31s
2024-07-31 00:15:09 +02:00
Mikescher 646990b549 v0.0.490 documentation and extra-params in exerr
Build Docker and Deploy / Run goext test-suite (push) Successful in 4m2s
2024-07-27 23:44:18 +02:00
Mikescher e5818146a8 v0.0.489
Build Docker and Deploy / Run goext test-suite (push) Successful in 3m54s
2024-07-23 14:21:03 +02:00
Mikescher 1310054121 v0.0.488 fix wpdf with 16bpp images
Build Docker and Deploy / Run goext test-suite (push) Successful in 4m9s
2024-07-22 15:16:28 +02:00
Mikescher 49d423915c v0.0.487
Build Docker and Deploy / Run goext test-suite (push) Successful in 2m54s
2024-07-18 17:45:56 +02:00
Mikescher 1962cb3c52 v0.0.486 add ginext -> CorsAllowHeader
Build Docker and Deploy / Run goext test-suite (push) Successful in 3m51s
2024-07-18 17:29:18 +02:00
Mikescher 84f124dd4d v0.0.485
Build Docker and Deploy / Run goext test-suite (push) Successful in 2m44s
2024-07-16 15:22:18 +02:00
Mikescher ff8e066135 v0.0.484
Build Docker and Deploy / Run goext test-suite (push) Failing after 1m58s
2024-07-16 15:16:56 +02:00
Mikescher bc5c61e43d v0.0.483
Build Docker and Deploy / Run goext test-suite (push) Successful in 3m37s
2024-07-16 15:08:37 +02:00
Mikescher 6ded615723 v0.0.482 mathext.Percentile
Build Docker and Deploy / Run goext test-suite (push) Successful in 3m46s
2024-07-12 16:33:42 +02:00
Mikescher abc8af525a v0.0.481
Build Docker and Deploy / Run goext test-suite (push) Successful in 3m50s
2024-07-04 16:24:49 +02:00
Mikescher 19d943361b v0.0.480
Build Docker and Deploy / Run goext test-suite (push) Successful in 1m20s
2024-07-02 11:32:22 +02:00
Mikescher b464afae01 v0.0.479 AccessStruct
Build Docker and Deploy / Run goext test-suite (push) Successful in 1m36s
2024-07-02 11:29:47 +02:00
Mikescher 56bc5e8285 v0.0.478
Build Docker and Deploy / Run goext test-suite (push) Successful in 3m41s
2024-07-01 17:23:00 +02:00
Mikescher cb95bb561c v0.0.477 add langext.StrWrap
Build Docker and Deploy / Run goext test-suite (push) Successful in 3m4s
2024-06-29 15:36:39 +02:00
Mikescher dff8941bd3 v0.0.476 Ãproperly close cursor in wmo
Build Docker and Deploy / Run goext test-suite (push) Successful in 3m44s
2024-06-28 18:37:02 +02:00
Mikescher 78e1c33e30 v0.0.475 ArrGroupBy
Build Docker and Deploy / Run goext test-suite (push) Successful in 3m36s
2024-06-16 17:14:21 +02:00
Mikescher d2f2a0558a v0.0.474 Add ZeroLogger config field to exerr.Init to override used zerolog instance
Build Docker and Deploy / Run goext test-suite (push) Successful in 1m27s
2024-06-14 23:18:58 +02:00
Mikescher fc4bed4b9f v0.0.473 add ctx to wmo.FilterQuery|Sort|Pagination
Build Docker and Deploy / Run goext test-suite (push) Successful in 1m23s
2024-06-14 17:24:59 +02:00
julian 94a7bf250d v0.0.472 changed gin engine initialization
Build Docker and Deploy / Run goext test-suite (push) Successful in 3m34s
2024-06-14 14:56:41 +02:00
Mikescher f6121a6961 v0.0.471 Revert "v0.0.470 Add GoextJsonMarshaller interface to call when marshalling json via gojson"
Build Docker and Deploy / Run goext test-suite (push) Successful in 2m50s
2024-06-11 19:39:43 +02:00
Mikescher 7fc73f1e93 v0.0.470 Add GoextJsonMarshaller interface to call when marshalling json via gojson
Build Docker and Deploy / Run goext test-suite (push) Successful in 2m51s
2024-06-11 19:34:48 +02:00
Mikescher 2504ef00a0 v0.0.469
Build Docker and Deploy / Run goext test-suite (push) Successful in 3m26s
2024-06-11 12:10:49 +02:00
TimoV fc5803493c added DblPtrIfNotNil
Build Docker and Deploy / Run goext test-suite (push) Successful in 1m31s
2024-06-05 17:53:57 +02:00
TimoV a9295bfabf added CoalesceDblPtr
Build Docker and Deploy / Run goext test-suite (push) Successful in 1m37s
2024-06-05 15:10:31 +02:00
Mikescher 12fa53d848 v0.0.466 exerr.Wrap now inherits the Severity of the wrapped error
Build Docker and Deploy / Run goext test-suite (push) Successful in 1m24s
2024-06-03 13:48:30 +02:00
Mikescher d2bb362135 v0.0.465
Build Docker and Deploy / Run goext test-suite (push) Successful in 3m39s
2024-06-03 09:39:57 +02:00
Mikescher 9dd81f6bd5 v0.0.464 improve ZeroLogErrTraces/ZeroLogAllTraces output for empty-message wrapped exerrs
Build Docker and Deploy / Run goext test-suite (push) Successful in 4m36s
2024-06-01 02:40:48 +02:00
Mikescher d2c04afcd5 v0.0.463 Fix SubtractYears
Build Docker and Deploy / Run goext test-suite (push) Failing after 3m29s
2024-05-29 20:20:01 +02:00
Mikescher 62980e1489 v0.0.462
Build Docker and Deploy / Run goext test-suite (push) Successful in 3m32s
2024-05-23 14:37:05 +02:00
Mikescher 59963adf74 v0.0.461
Build Docker and Deploy / Run goext test-suite (push) Successful in 1m17s
2024-05-20 00:52:49 +02:00
Mikescher 194ea4ace5 v0.0.460
Build Docker and Deploy / Run goext test-suite (push) Successful in 1m16s
2024-05-20 00:38:04 +02:00
Mikescher 73b80a66bc v0.0.459
Build Docker and Deploy / Run goext test-suite (push) Successful in 1m16s
2024-05-20 00:20:31 +02:00
Mikescher d8b2d01274 v0.0.458 revert 457 and fix ObjectFitImage
Build Docker and Deploy / Run goext test-suite (push) Successful in 2m49s
2024-05-20 00:15:24 +02:00
Mikescher bfa8457e95 v0.0.457 test
Build Docker and Deploy / Run goext test-suite (push) Successful in 3m38s
2024-05-20 00:07:33 +02:00
Mikescher 70106733d9 v0.0.456
Build Docker and Deploy / Run goext test-suite (push) Successful in 2m38s
2024-05-18 23:38:47 +02:00
Mikescher ce7837b9ef v0.0.455 add proper json/bson marshalling to exerr [severity|type|category]
Build Docker and Deploy / Run goext test-suite (push) Successful in 3m39s
2024-05-16 15:38:42 +02:00
Mikescher d0d72167eb v0.0.454
Build Docker and Deploy / Run goext test-suite (push) Successful in 1m17s
2024-05-14 15:10:27 +02:00
Mikescher a55ee1a6ce v0.0.453
Build Docker and Deploy / Run goext test-suite (push) Successful in 1m18s
2024-05-14 14:57:10 +02:00
Mikescher dfc319573c v0.0.452
Build Docker and Deploy / Run goext test-suite (push) Successful in 3m16s
2024-05-14 12:48:43 +02:00
Mikescher 246e555f3f v0.0.451 wpdf image processing
Build Docker and Deploy / Run goext test-suite (push) Has been cancelled
2024-05-14 12:46:49 +02:00
Mikescher c28bc086b2 v0.0.450 wpdf
Build Docker and Deploy / Run goext test-suite (push) Failing after 3m33s
2024-05-14 11:52:56 +02:00
Mikescher d44e971325 v0.0.449
Build Docker and Deploy / Run goext test-suite (push) Successful in 1m15s
2024-05-12 16:51:52 +02:00
Mikescher fe4cdc48af v0.0.448 wmo marshalHook
Build Docker and Deploy / Run goext test-suite (push) Failing after 25s
2024-05-12 16:45:45 +02:00
Mikescher 631006a4e1 v0.0.447
Build Docker and Deploy / Run goext test-suite (push) Successful in 3m18s
2024-05-10 21:33:01 +02:00
Mikescher 567ead8697 v0.0.446 syncMap.GetAndSetIfNotContains and CASMutex
Build Docker and Deploy / Run goext test-suite (push) Has been cancelled
2024-05-10 21:31:36 +02:00
Mikescher e4886b4a7d v0.0.445 added CtxData() and ExtendGinMeta/ExtendContextMeta to exerr
Build Docker and Deploy / Run goext test-suite (push) Successful in 1m55s
2024-05-03 15:28:53 +02:00
Mikescher dcb5d3d7cd v0.0.444 change gin values in exerr auto meta to not include dots in keys (fucks up mongo)
Build Docker and Deploy / Run goext test-suite (push) Failing after 3m9s
2024-05-03 13:24:08 +02:00
Mikescher 15a639f85a v0.0.443 fix wmo.List with pageSize==0
Build Docker and Deploy / Run goext test-suite (push) Failing after 3m9s
2024-05-03 11:56:29 +02:00
Mikescher 303bd04649 v0.0.442
Build Docker and Deploy / Run goext test-suite (push) Successful in 2m0s
2024-04-29 17:24:10 +02:00
Mikescher 7bda674939 v0.0.441
Build Docker and Deploy / Run goext test-suite (push) Failing after 2m29s
2024-04-29 17:19:55 +02:00
Mikescher 126d4fbd0b v0.0.440 improve exerr.toJson
Build Docker and Deploy / Run goext test-suite (push) Failing after 2m54s
2024-04-29 16:03:58 +02:00
Mikescher fed8bccaab v0.0.439
Build Docker and Deploy / Run goext test-suite (push) Successful in 1m38s
2024-04-25 11:47:16 +02:00
Mikescher 47b6a6b508 v0.0.438
Build Docker and Deploy / Run goext test-suite (push) Successful in 3m20s
2024-04-25 11:40:01 +02:00
Mikescher 764ce79a71 v0.0.437 properly handle $group in wmo extraModPipeline
Build Docker and Deploy / Run goext test-suite (push) Failing after 3m23s
2024-04-23 16:12:17 +02:00
Mikescher b876c64ba2 v0.0.436
Build Docker and Deploy / Run goext test-suite (push) Successful in 1m56s
2024-04-18 14:09:26 +02:00
Mikescher 8d52b41f57 v0.0.435 add ConvertStructToMapOpt.MaxDepth
Build Docker and Deploy / Run goext test-suite (push) Successful in 2m39s
2024-04-15 12:55:44 +02:00
Mikescher f47e2a33fe v0.0.434
Build Docker and Deploy / Run goext test-suite (push) Successful in 1m13s
2024-04-15 10:43:26 +02:00
Mikescher 9321938dad v0.0.433 fix exerr missing gindata when using ginext.Error and add config for Output logging to stderr
Build Docker and Deploy / Run goext test-suite (push) Successful in 4m0s
2024-04-15 10:25:30 +02:00
Mikescher 3828d601a2 v0.0.432 better handling of unmarshall-able values in exerr meta
Build Docker and Deploy / Run goext test-suite (push) Successful in 2m12s
2024-04-13 22:08:45 +02:00
Mikescher 2e713c808d v0.0.431
Build Docker and Deploy / Run goext test-suite (push) Failing after 1m10s
2024-04-10 15:29:59 +02:00
Mikescher 6602f86b43 v0.0.430
Build Docker and Deploy / Run goext test-suite (push) Has been cancelled
2024-04-10 15:27:41 +02:00
Mikescher 24d9f0fdc7 v0.0.429
Build Docker and Deploy / Run goext test-suite (push) Successful in 3m23s
2024-04-08 16:33:44 +02:00
Mikescher 8446b2da22 v0.0.428
Build Docker and Deploy / Run goext test-suite (push) Has been cancelled
2024-04-08 16:32:34 +02:00
Mikescher 758e5a67b5 v0.0.427
Build Docker and Deploy / Run goext test-suite (push) Successful in 3m30s
2024-04-07 15:10:21 +02:00
Mikescher 678ddd7124 v0.0.426 fix JsonOpt
Build Docker and Deploy / Run goext test-suite (push) Successful in 3m57s
2024-04-01 16:03:00 +02:00
Mikescher 36b71dfaf3 v0.0.425 ArrAppend
Build Docker and Deploy / Run goext test-suite (push) Successful in 2m25s
2024-03-30 14:24:53 +01:00
Mikescher 9491b72b8d v0.0.424 timeext.SubtractYears
Build Docker and Deploy / Run goext test-suite (push) Successful in 3m29s
2024-03-30 03:01:55 +01:00
Mikescher 6c4af4006b v0.0.423 fix createPaginationPipeline - different primary and secondary sort keys broke mongo ??!?? - it actually only sorted by the secondary condition (ignoring the primary?)
Build Docker and Deploy / Run goext test-suite (push) Successful in 1m32s
2024-03-24 15:25:52 +01:00
Mikescher 8bf3a337cf v0.0.422
Build Docker and Deploy / Run goext test-suite (push) Successful in 1m29s
2024-03-23 20:29:46 +01:00
Mikescher 16146494dc v0.0.421
Build Docker and Deploy / Run goext test-suite (push) Failing after 32s
2024-03-23 20:28:51 +01:00
Mikescher b0e443ad99 v0.0.420
Build Docker and Deploy / Run goext test-suite (push) Failing after 36s
2024-03-23 18:01:41 +01:00
Mikescher 9955eacf96 v0.0.419 JsonOpt
Build Docker and Deploy / Run goext test-suite (push) Failing after 39s
2024-03-23 17:49:56 +01:00
Mikescher f0347a9435 v0.0.418 fix tests?
Build Docker and Deploy / Run goext test-suite (push) Successful in 3m1s
2024-03-20 09:42:06 +01:00
Mikescher 7c869c65f3 v0.0.417 add GinWrapper.ForwardRequest
Build Docker and Deploy / Run goext test-suite (push) Failing after 1m26s
2024-03-20 08:58:59 +01:00
Mikescher 14f39a9162 v0.0.416
Build Docker and Deploy / Run goext test-suite (push) Failing after 1m43s
2024-03-18 11:19:01 +01:00
Mikescher dcd106c1cd v0.0.415 add 'tagkey' to gojson.Decoder
Build Docker and Deploy / Run goext test-suite (push) Successful in 3m45s
2024-03-18 10:42:00 +01:00
Mikescher b704e2a362 v0.0.414 fix rfctime.Date bson marshalling for zero value
Build Docker and Deploy / Run goext test-suite (push) Successful in 2m28s
2024-03-16 19:42:59 +01:00
Mikescher 6b4bd5a6f8 v0.0.413 fix tests
Build Docker and Deploy / Run goext test-suite (push) Successful in 1m33s
2024-03-11 21:00:30 +01:00
Mikescher 6df4f5f2a1 v0.0.412 fix GenerateIDSpecs accepting nil for opt
Build Docker and Deploy / Run goext test-suite (push) Failing after 1m32s
2024-03-11 20:58:06 +01:00
Mikescher 780905ba35 v0.0.411
Build Docker and Deploy / Run goext test-suite (push) Successful in 1m33s
2024-03-11 20:43:37 +01:00
Mikescher c679797765 v0.0.410 add ginext.SuppressGinLogs
Build Docker and Deploy / Run goext test-suite (push) Has been cancelled
2024-03-11 20:42:12 +01:00
Mikescher 401aad9fa4 v0.0.409
Build Docker and Deploy / Run goext test-suite (push) Successful in 1m41s
2024-03-11 17:05:10 +01:00
Mikescher 645113d553 v0.0.408
Build Docker and Deploy / Run goext test-suite (push) Successful in 3m43s
2024-03-11 16:41:47 +01:00
Mikescher 4a33986b6a v0.0.407 sq.Iterate
Build Docker and Deploy / Run goext test-suite (push) Has been cancelled
2024-03-11 16:40:41 +01:00
Mikescher c1c8c64c76 v0.0.406 bf
Build Docker and Deploy / Run goext test-suite (push) Successful in 1m34s
2024-03-10 16:44:21 +01:00
Mikescher 0927fdc4d7 v0.0.405
Build Docker and Deploy / Run goext test-suite (push) Successful in 1m36s
2024-03-10 15:28:26 +01:00
Mikescher 102a280dda v0.0.404
Build Docker and Deploy / Run goext test-suite (push) Successful in 1m37s
2024-03-10 15:25:30 +01:00
Mikescher f13384d794 v0.0.403 bf
Build Docker and Deploy / Run goext test-suite (push) Successful in 1m37s
2024-03-10 12:58:59 +01:00
Mikescher 409d6e108d v0.0.402 add PackageName() and TypeName() to enums_codegen
Build Docker and Deploy / Run goext test-suite (push) Successful in 2m12s
2024-03-10 12:49:31 +01:00
Mikescher ed53f297bd v0.0.401 bf
Build Docker and Deploy / Run goext test-suite (push) Successful in 1m27s
2024-03-09 15:07:03 +01:00
Mikescher 42424f4bc2 v0.0.400 added CommentTrimmer and DBOptions to sq
Build Docker and Deploy / Run goext test-suite (push) Successful in 2m16s
2024-03-09 14:59:32 +01:00
Mikescher 9e5b8c5277 v0.0.399 added sq.NewAutoDBTypeConverter
Build Docker and Deploy / Run goext test-suite (push) Successful in 1m25s
2024-03-09 14:16:35 +01:00
Mikescher 9abe28c490 v0.0.398 added As* version to sort functions
Build Docker and Deploy / Run goext test-suite (push) Successful in 3m13s
2024-03-09 13:36:06 +01:00
Mikescher 422bbd8593 v0.0.397 added wmo.IColl inetrface for tim
Build Docker and Deploy / Run goext test-suite (push) Successful in 4m2s
2024-03-04 12:17:10 +01:00
Mikescher 3956675e04 v0.0.396 sq.ConverterRFCSecondsF64ToString
Build Docker and Deploy / Run goext test-suite (push) Successful in 3m2s
2024-02-28 14:28:48 +01:00
Mikescher 10c3780b52 v0.0.395 MapMerge
Build Docker and Deploy / Run goext test-suite (push) Successful in 3m14s
2024-02-27 13:42:06 +01:00
Mikescher 8edc067a3b v0.0.394
Build Docker and Deploy / Run goext test-suite (push) Successful in 1m31s
2024-02-21 18:40:42 +01:00
Mikescher 1007f2c834 v0.0.393
Build Docker and Deploy / Run goext test-suite (push) Successful in 3m19s
2024-02-21 18:33:18 +01:00
Mikescher c25da03217 v0.0.392
Build Docker and Deploy / Run goext test-suite (push) Has been cancelled
2024-02-21 18:32:06 +01:00
Mikescher 4b55dbaacf v0.0.391
Build Docker and Deploy / Run goext test-suite (push) Successful in 1m36s
2024-02-21 16:18:04 +01:00
Mikescher c399fa42ae v0.0.390
Build Docker and Deploy / Run goext test-suite (push) Successful in 3m26s
2024-02-21 16:11:15 +01:00
Mikescher 9e586f7706 v0.0.389
Build Docker and Deploy / Run goext test-suite (push) Has been cancelled
2024-02-21 16:10:28 +01:00
Mikescher 3cc8dccc63 v0.0.388 fix ginext.Use loosing absPath information
Build Docker and Deploy / Run goext test-suite (push) Successful in 3m26s
2024-02-20 09:19:06 +01:00
Mikescher 7fedfbca81 v0.0.387 bf
Build Docker and Deploy / Run goext test-suite (push) Successful in 3m24s
2024-02-12 18:17:49 +01:00
Mikescher 3c439ba428 v0.0.386 InsertAndQuerySingle
Build Docker and Deploy / Run goext test-suite (push) Successful in 1m27s
2024-02-09 15:58:21 +01:00
Mikescher ad24f6db44 v0.0.385 UpdateAndQuerySingle
Build Docker and Deploy / Run goext test-suite (push) Successful in 1m29s
2024-02-09 15:40:09 +01:00
Mikescher 1869ff3d75 v0.0.384 QuerySingleOpt
Build Docker and Deploy / Run goext test-suite (push) Successful in 1m28s
2024-02-09 15:20:46 +01:00
Mikescher 30ce8c4b60 v0.0.383 sq.InsertMultiple
Build Docker and Deploy / Run goext test-suite (push) Successful in 2m15s
2024-02-09 15:17:51 +01:00
TimoV 885bb53244 add BuildUrl test
Build Docker and Deploy / Run goext test-suite (push) Successful in 2m59s
2024-02-09 12:27:18 +01:00
TimoV 1c7dc1820a v0.0.382 add build url method
Build Docker and Deploy / Run goext test-suite (push) Has been cancelled
2024-02-09 12:25:01 +01:00
Mikescher 7e16e799e4 v0.0.381
Build Docker and Deploy / Run goext test-suite (push) Successful in 1m28s
2024-01-23 17:51:52 +01:00
Mikescher 890e16241d v0.0.380 exerr properly handle inf and nan floats
Build Docker and Deploy / Run goext test-suite (push) Successful in 3m8s
2024-01-22 12:33:41 +01:00
Mikescher b9d0348735 v0.0.379
Build Docker and Deploy / Run goext test-suite (push) Successful in 2m57s
2024-01-19 17:30:20 +01:00
Mikescher b9e9575b9b v0.0.378
Build Docker and Deploy / Run goext test-suite (push) Successful in 3m18s
2024-01-16 15:04:10 +01:00
Mikescher 295a098eb4 v0.0.377 fix sq.ConverterBoolToBit
Build Docker and Deploy / Run goext test-suite (push) Successful in 2m12s
2024-01-14 17:06:42 +01:00
Mikescher b69a082bb1 v0.0.376
Build Docker and Deploy / Run goext test-suite (push) Successful in 1m22s
2024-01-14 01:52:52 +01:00
Mikescher a4a8c83d17 v0.0.375
Build Docker and Deploy / Run goext test-suite (push) Successful in 1m25s
2024-01-14 01:50:48 +01:00
Mikescher e952176bb0 v0.0.374 ppwgen
Build Docker and Deploy / Run goext test-suite (push) Successful in 1m47s
2024-01-14 01:37:38 +01:00
Mikescher d99adb203b v0.0.373 BuildInsertStatement
Build Docker and Deploy / Run goext test-suite (push) Successful in 1m24s
2024-01-14 00:07:01 +01:00
Mikescher f1f91f4cfa v0.0.372 sq.Paginate
Build Docker and Deploy / Run goext test-suite (push) Successful in 1m25s
2024-01-13 21:36:47 +01:00
Mikescher 2afb265ea4 v0.0.371
Build Docker and Deploy / Run goext test-suite (push) Successful in 1m26s
2024-01-13 14:19:19 +01:00
Mikescher be24f7a190 v0.0.370 improve sq errors
Build Docker and Deploy / Run goext test-suite (push) Successful in 1m36s
2024-01-13 14:10:25 +01:00
Mikescher aae8a706e9 v0.0.369 autom. allow usage of existing converter for pointer-types (sq)
Build Docker and Deploy / Run goext test-suite (push) Successful in 1m29s
2024-01-13 02:01:30 +01:00
Mikescher 7d64f18f54 v0.0.368
Build Docker and Deploy / Run goext test-suite (push) Successful in 2m9s
2024-01-13 01:29:40 +01:00
Mikescher d08b2e565a v0.0.367
Build Docker and Deploy / Run goext test-suite (push) Successful in 1m32s
2024-01-12 18:40:29 +01:00
Mikescher d29e84894d v0.0.366 ginext: set cookies
Build Docker and Deploy / Run goext test-suite (push) Successful in 1m28s
2024-01-12 15:10:48 +01:00
Mikescher 617298c366 v0.0.365
Build Docker and Deploy / Run goext test-suite (push) Successful in 1m24s
2024-01-09 18:23:46 +01:00
Tim 668f308565 v0.0.364 add ServerHTTP() to GinWrapper for integration testing
Build Docker and Deploy / Run goext test-suite (push) Successful in 2m59s
2024-01-09 18:17:55 +01:00
Mikescher 240a8ed7aa v0.0.363 wmo.extraModPipeline as func
Build Docker and Deploy / Run goext test-suite (push) Successful in 3m9s
2024-01-09 08:51:46 +01:00
Mikescher 70de8e8d04 v0.0.362
Build Docker and Deploy / Run goext test-suite (push) Successful in 1m27s
2024-01-07 04:18:03 +01:00
Mikescher d38fa60fbc v0.0.361 call exerrListener in ginext.Error
Build Docker and Deploy / Run goext test-suite (push) Successful in 1m37s
2024-01-07 04:01:13 +01:00
Mikescher 5fba7e0e2f v0.0.360 bf
Build Docker and Deploy / Run goext test-suite (push) Successful in 1m26s
2024-01-06 01:31:07 +01:00
Mikescher 8757643399 v0.0.359 fix tests
Build Docker and Deploy / Run goext test-suite (push) Successful in 2m18s
2024-01-05 16:55:53 +01:00
Mikescher 42bd4cf58d v0.0.358
Build Docker and Deploy / Run goext test-suite (push) Failing after 1m22s
2024-01-05 16:53:14 +01:00
Mikescher 413178e2d3 v0.0.357
Build Docker and Deploy / Run goext test-suite (push) Successful in 1m4s
2024-01-05 10:59:06 +01:00
Mikescher 9264a2e99b v0.0.356 exerr.GetMeta
Build Docker and Deploy / Run goext test-suite (push) Successful in 1m9s
2024-01-05 10:43:39 +01:00
Mikescher 2a0471fb3d v0.0.355 sq.Json
Build Docker and Deploy / Run goext test-suite (push) Successful in 2m27s
2024-01-05 10:25:05 +01:00
Mikescher 1497c013f9 v0.0.354
Build Docker and Deploy / Run goext test-suite (push) Successful in 3m13s
2024-01-05 07:21:43 +01:00
Mikescher ef78b7467b v0.0.353 add scn.sendmessage
Build Docker and Deploy / Run goext test-suite (push) Successful in 4m16s
2024-01-04 12:38:03 +01:00
Mikescher 0eda32b725 v0.0.352
Build Docker and Deploy / Run goext test-suite (push) Successful in 1m1s
2023-12-29 19:29:36 +01:00
Mikescher f9ccafb976 v0.0.351 sq value converter
Build Docker and Deploy / Run goext test-suite (push) Successful in 2m30s
2023-12-29 19:25:36 +01:00
Mikescher 6e90239fef v0.0.350
Build Docker and Deploy / Run goext test-suite (push) Successful in 1m2s
2023-12-29 18:06:45 +01:00
Mikescher 05580c384a v0.0.349
Build Docker and Deploy / Run goext test-suite (push) Successful in 1m51s
2023-12-28 01:36:21 +01:00
Mikescher 3188b951fb v0.0.348 added listener and options to goext
Build Docker and Deploy / Run goext test-suite (push) Successful in 2m35s
2023-12-27 20:29:37 +01:00
Mikescher 6b211d1443 fix tests
Build Docker and Deploy / Run goext test-suite (push) Successful in 1m37s
2023-12-17 14:04:35 +01:00
Mikescher b2b9b40792 v0.0.347
Build Docker and Deploy / Run goext test-suite (push) Failing after 1m39s
2023-12-16 17:57:42 +01:00
Mikescher 2f915cb6c1 v0.0.346
Build Docker and Deploy / Run goext test-suite (push) Failing after 1m56s
2023-12-13 16:22:15 +01:00
Mikescher b2b93f570a v0.0.345
Build Docker and Deploy / Run goext test-suite (push) Failing after 2m48s
2023-12-07 19:39:31 +01:00
Mikescher 8247fc4524 v0.0.344
Build Docker and Deploy / Run goext test-suite (push) Failing after 1m11s
2023-12-07 19:36:21 +01:00
Mikescher 5dad44ad09 v0.0.343
Build Docker and Deploy / Run goext test-suite (push) Failing after 1m2s
2023-12-07 18:29:17 +01:00
Mikescher f042183433 v0.0.342 support json data in enum comment
Build Docker and Deploy / Run goext test-suite (push) Failing after 1m29s
2023-12-07 17:57:06 +01:00
Mikescher b0be93a7a0 v0.0.341
Build Docker and Deploy / Run goext test-suite (push) Failing after 1m6s
2023-12-07 14:43:12 +01:00
Mikescher 1c143921e6 v0.0.340
Build Docker and Deploy / Run goext test-suite (push) Has been cancelled
2023-12-07 14:42:25 +01:00
Mikescher 68e63a9cf6 v0.0.339
Build Docker and Deploy / Run goext test-suite (push) Failing after 1m5s
2023-12-07 10:54:36 +01:00
Mikescher c3162fec95 v0.0.338
Build Docker and Deploy / Run goext test-suite (push) Failing after 59s
2023-12-05 19:50:24 +01:00
Mikescher 1124aa781a v0.0.337
Build Docker and Deploy / Run goext test-suite (push) Failing after 1m6s
2023-12-05 19:45:35 +01:00
Mikescher eef0e9f2aa v0.0.336
Build Docker and Deploy / Run goext test-suite (push) Failing after 1m3s
2023-12-05 19:42:37 +01:00
Mikescher af38b06d22 v0.0.335 added DescriptionMeta to enum codegen
Build Docker and Deploy / Run goext test-suite (push) Failing after 58s
2023-12-05 19:38:03 +01:00
Mikescher 2fad6340c7 v0.0.334 allow dot in enum-value
Build Docker and Deploy / Run goext test-suite (push) Failing after 1m3s
2023-12-05 19:23:27 +01:00
Mikescher 03aa0a2282 Merge branch 'feature/gmail_api'
Build Docker and Deploy / Run goext test-suite (push) Failing after 1m23s
2023-12-04 13:56:18 +01:00
Mikescher 358c238f3d google mail API [[FIN]]
Build Docker and Deploy / Run goext test-suite (push) Failing after 1m28s
2023-12-04 13:55:41 +01:00
Mikescher d65ac8ba2b v0.0.329
Build Docker and Deploy / Run goext test-suite (push) Successful in 1m2s
2023-12-02 13:38:17 +01:00
Mikescher 55d02b8c65 v0.0.328
Build Docker and Deploy / Run goext test-suite (push) Successful in 59s
2023-12-02 13:35:18 +01:00
Mikescher 8a3965f666 v0.0.327
Build Docker and Deploy / Run goext test-suite (push) Successful in 1m44s
2023-12-02 13:15:19 +01:00
Mikescher 4aa2f494b1 v0.0.326 ginext::WithSession
Build Docker and Deploy / Run goext test-suite (push) Successful in 1m38s
2023-12-02 13:07:36 +01:00
Mikescher 8f13eb2f16 google mail API [[[WIP]]] 2023-12-01 18:33:04 +01:00
Mikescher 8f15d42173 v0.0.325
Build Docker and Deploy / Run goext test-suite (push) Failing after 2m48s
2023-11-27 14:14:58 +01:00
Mikescher 07fa21dcca v0.0.324
Build Docker and Deploy / Run goext test-suite (push) Successful in 2m29s
2023-11-25 15:48:28 +01:00
Mikescher e657de7f78 v0.0.323 fix langext.IsNil for reflect.Array
Build Docker and Deploy / Run goext test-suite (push) Successful in 1m35s
2023-11-16 17:15:44 +01:00
Mikescher c534e998e8 v0.0.322 bf SecondsF64
Build Docker and Deploy / Run goext test-suite (push) Successful in 1m1s
2023-11-14 16:31:05 +01:00
Mikescher 88642770c5 v0.0.321 Add .NoLog() to lowest-level query exerr.Wrap in wmo (otherwise we get error logs on stdout even if the callign method allows mongo.ErrNoDocuments)
Build Docker and Deploy / Run goext test-suite (push) Successful in 1m4s
2023-11-14 16:00:14 +01:00
TimoV 8528b5cb66 v0.0.320 bugfix sort
Build Docker and Deploy / Run goext test-suite (push) Successful in 1m14s
2023-11-14 14:50:27 +01:00
Mikescher 5ba84bd8ee v0.0.319 fix error when findoneÃ+pipeline fails
Build Docker and Deploy / Run goext test-suite (push) Successful in 1m15s
2023-11-13 16:45:00 +01:00
Mikescher 1260b2dc77 v0.0.318 add failure mail to testx.yml
Build Docker and Deploy / Run goext test-suite (push) Successful in 1m9s
2023-11-13 15:34:58 +01:00
Mikescher 7d18b913c6 v0.0.317 try fix tests on pipeline
Build Docker and Deploy / Run goext test-suite (push) Successful in 2m38s
2023-11-13 15:28:37 +01:00
TimoV d1f9069f2f v0.0.316 bugfix sorting
Build Docker and Deploy / Run goext test-suite (push) Failing after 2m14s
2023-11-13 15:19:48 +01:00
Mikescher fa6d73301e v0.0.315 atomic
Build Docker and Deploy / Run goext test-suite (push) Failing after 1m26s
2023-11-12 03:10:55 +01:00
Mikescher bfe62799d3 v0.0.314
Build Docker and Deploy / Run goext test-suite (push) Failing after 1m22s
2023-11-10 13:37:55 +01:00
Mikescher ede912eb7b v0.0.313
Build Docker and Deploy / Run goext test-suite (push) Failing after 1m22s
2023-11-10 13:26:30 +01:00
Mikescher ff8f128fe8 v0.0.312 improve exerr.RecursiveMessage()
Build Docker and Deploy / Run goext test-suite (push) Failing after 1m23s
2023-11-10 10:16:31 +01:00
Mikescher 1971f1396f v0.0.311 BF
Build Docker and Deploy / Run goext test-suite (push) Failing after 1m17s
2023-11-09 11:48:45 +01:00
Mikescher bf6c184d12 v0.0.310 debug
Build Docker and Deploy / Run goext test-suite (push) Failing after 1m18s
2023-11-09 11:40:48 +01:00
Mikescher 770f5c5c64 v0.0.309
Build Docker and Deploy / Run goext test-suite (push) Failing after 1m17s
2023-11-09 10:17:29 +01:00
Mikescher 623c021689 v0.0.308
Build Docker and Deploy / Run goext test-suite (push) Failing after 1m20s
2023-11-09 10:02:31 +01:00
Mikescher afcc89bf9e v0.0.307
Build Docker and Deploy / Run goext test-suite (push) Failing after 1m20s
2023-11-09 10:00:01 +01:00
Mikescher 1672e8f8fd v0.0.306
Build Docker and Deploy / Run goext test-suite (push) Failing after 1m12s
2023-11-09 09:36:41 +01:00
Mikescher 398ed56d32 v0.0.305
Build Docker and Deploy / Run goext test-suite (push) Failing after 1m21s
2023-11-09 09:35:56 +01:00
Mikescher f3ecba3883 v0.0.304 add support for WithModifyingPipeline to wmo
Build Docker and Deploy / Run goext test-suite (push) Failing after 1m22s
2023-11-09 09:26:46 +01:00
Mikescher 45031b05cf v0.0.303
Build Docker and Deploy / Run goext test-suite (push) Failing after 1m35s
2023-11-08 19:01:15 +01:00
Mikescher 7413ea045d v0.0.302
Build Docker and Deploy / Run goext test-suite (push) Failing after 1m41s
2023-11-08 18:53:02 +01:00
Mikescher 62c9a4e734 v0.0.301 pagination (page+limit) support in wmo
Build Docker and Deploy / Run goext test-suite (push) Failing after 2m11s
2023-11-08 18:30:30 +01:00
Mikescher 3a8baaa6d9 v0.0.300 add custom unmarshal-hooks to wmo
Build Docker and Deploy / Run goext test-suite (push) Successful in 1m45s
2023-11-04 18:55:44 +01:00
Mikescher 498785e213 v0.0.299 pctx.RawBody( *[]byte )
Build Docker and Deploy / Run goext test-suite (push) Successful in 1m41s
2023-11-03 16:53:41 +01:00
Mikescher 678f95642c v0.0.298 use go/format instead of manual command invocation
Build Docker and Deploy / Run goext test-suite (push) Successful in 1m6s
2023-11-01 04:20:08 +01:00
Mikescher dacc97e2ce v0.0.297
Build Docker and Deploy / Run goext test-suite (push) Successful in 1m1s
2023-11-01 00:31:51 +01:00
Mikescher f8c0c0afa0 v0.0.296 add csid.generateIDFromSeed
Build Docker and Deploy / Run goext test-suite (push) Successful in 1m4s
2023-11-01 00:29:58 +01:00
Mikescher 2fbd5cf965 v0.0.295 added generic base-conversion algorithm
Build Docker and Deploy / Run goext test-suite (push) Successful in 1m4s
2023-11-01 00:23:17 +01:00
Mikescher 75f71fe3db v0.0.294 migrate bfcodegen to templates
Build Docker and Deploy / Run goext test-suite (push) Successful in 58s
2023-10-31 22:58:28 +01:00
Mikescher ab1a1ab6f6 v0.0.293 fix NPE
Build Docker and Deploy / Run goext test-suite (push) Successful in 1m52s
2023-10-30 13:37:31 +01:00
Mikescher 19ee5019ef v0.0.292
Build Docker and Deploy / Run goext test-suite (push) Successful in 3m11s
2023-10-30 10:14:38 +01:00
Mikescher 42b68507f2 v0.0.291
Build Docker and Deploy / Run goext test-suite (push) Successful in 56s
2023-10-26 13:02:45 +02:00
Mikescher 9d0047a11e v0.0.290 csid
Build Docker and Deploy / Run goext test-suite (push) Successful in 1m38s
2023-10-26 13:01:58 +02:00
Mikescher 06d81f1682 v0.0.289 fsext
Build Docker and Deploy / Run goext test-suite (push) Successful in 2m31s
2023-10-26 11:29:08 +02:00
Mikescher 7b8ab03779 v0.0.288 default to recursive-error-msg in exerr.Error()
Build Docker and Deploy / Run goext test-suite (push) Successful in 2m54s
2023-10-19 14:16:01 +02:00
Mikescher 07cbcf5a0a v0.0.287 fix bug in confext::ApplyEnvOverrides if a struct env key exists in the os.env
Build Docker and Deploy / Run goext test-suite (push) Successful in 1m37s
2023-10-12 10:02:42 +02:00
Mikescher da41ec3e84 run CICD tests without doker workaround
Build Docker and Deploy / Run goext test-suite (push) Successful in 1m37s
2023-10-11 15:50:09 +02:00
Mikescher 592fae25af v0.0.286 allow spaces in enum-keys
Build Docker and Deploy / Run goext test-suite (push) Successful in 1m14s
2023-10-11 11:27:18 +02:00
Mikescher 7968460fa2 v0.0.285
Build Docker and Deploy / Run goext test-suite (push) Failing after 1m0s
2023-10-09 15:25:30 +02:00
Mikescher b808c5727c v0.0.284
Build Docker and Deploy / Run goext test-suite (push) Successful in 1m14s
2023-10-09 15:22:57 +02:00
Mikescher 796f7956b8 v0.0.283
Build Docker and Deploy / Run goext test-suite (push) Failing after 1m5s
2023-10-09 15:17:22 +02:00
TimoV 1e6b92d1d9 v0.0.282 ginext bugfix
Build Docker and Deploy / Run goext test-suite (push) Failing after 48s
2023-10-09 09:23:40 +02:00
TimoV 0b85fa5af9 v0.0.281 typo fix
Build Docker and Deploy / Run goext test-suite (push) Failing after 50s
2023-10-09 09:04:07 +02:00
TimoV c3318cc1de v0.0.280 DYN-166 ginext jsonfilter middleware
Build Docker and Deploy / Run goext test-suite (push) Successful in 51s
2023-10-09 09:02:37 +02:00
TimoV fbf4d7b915 v0.0.279 DYN-166 ginext jsonfilter middleware
Build Docker and Deploy / Run goext test-suite (push) Failing after 1m12s
2023-10-09 08:55:22 +02:00
TimoV 9cc0abf9e0 v0.0.278 DYN-166 bugfix jsonfilter
Build Docker and Deploy / Run goext test-suite (push) Failing after 52s
2023-10-05 12:54:07 +02:00
TimoV 7c40bcfd3c v0.0.277 DYN-166 json marshal filter in ginext Write
Build Docker and Deploy / Run goext test-suite (push) Failing after 54s
2023-10-05 12:00:51 +02:00
TimoV 05636a1e4d v0.0.276
Build Docker and Deploy / Run goext test-suite (push) Successful in 1m12s
2023-10-05 10:59:20 +02:00
TimoV 0f52b860ea DYN-166 add jsonfilter to json library
Build Docker and Deploy / Run goext test-suite (push) Successful in 56s
2023-10-05 10:57:34 +02:00
TimoV b5cd116219 DYN-166 add jsonfilter to json library
Build Docker and Deploy / Run goext test-suite (push) Successful in 47s
2023-10-05 10:45:09 +02:00
Mikescher 98486842ae v0.0.275 fix missing returns in (v MetaValue) ShortString
Build Docker and Deploy / Run goext test-suite (push) Successful in 54s
2023-09-29 16:00:40 +02:00
Mikescher 7577a2dd47 v0.0.274 limit exerr log meta values (shortlog) to 240 chars
Build Docker and Deploy / Run goext test-suite (push) Successful in 50s
2023-09-27 16:18:21 +02:00
Mikescher 08681756b6 v0.0.273 add stack to PanicWrappedErr
Build Docker and Deploy / Run goext test-suite (push) Successful in 1m9s
2023-09-27 14:15:59 +02:00
Robin 64772d0474 v0.0.272 WMO: fix FindOneAndReplace not using FindOneAndReplace
Build Docker and Deploy / Run goext test-suite (push) Failing after 50s
2023-09-26 14:41:15 +02:00
Robin 127764556e Merge branch 'master' of ssh://gogs.mikescher.com:8022/BlackForestBytes/goext 2023-09-26 14:41:06 +02:00
Robin 170f43d806 WMO: fix FindOneAndReplace not using FindOneAndReplace 2023-09-26 14:40:56 +02:00
Mikescher 9dffc41274 v0.0.271 return old value in AtomicBool::Set
Build Docker and Deploy / Run goext test-suite (push) Failing after 53s
2023-09-26 14:32:45 +02:00
Mikescher c63cf442f8 try to fix test 'cmdext:TestFailOnStderr'
Build Docker and Deploy / Run goext test-suite (push) Successful in 39s
2023-09-25 18:04:56 +02:00
Robin a2ba283632 v0.0.270 fix inversion of AssertDeepEqual
Build Docker and Deploy / Run goext test-suite (push) Failing after 54s
2023-09-25 11:35:03 +02:00
Robin 4a1fb1ae18 fix inversion of AssertDeepEqual 2023-09-25 11:34:51 +02:00
Robin a127b24e62 v0.0.269 add AssertSetDeepEqual
Build Docker and Deploy / Run goext test-suite (push) Successful in 1m8s
2023-09-25 09:18:22 +02:00
Robin 69d6290376 add AssertSetDeepEqual 2023-09-25 09:18:07 +02:00
Mikescher c08a739158 v0.0.268 added WeekStart() and WeekEnd()
Build Docker and Deploy / Run goext test-suite (push) Successful in 50s
2023-09-21 16:29:23 +02:00
Robin 5f5f0e44f0 v0.0.267 fix AssertDeepEqual
Build Docker and Deploy / Run goext test-suite (push) Failing after 50s
2023-09-21 14:15:02 +02:00
Robin 6e6797eac5 fix AssertDeepEqual 2023-09-21 14:14:51 +02:00
Robin cd9406900a v0.0.266 fix tst showing wrong file:line
Build Docker and Deploy / Run goext test-suite (push) Successful in 1m22s
2023-09-21 13:08:13 +02:00
Robin 6c81f7f6bc fix tst showing wrong file:line, add DeepEqual 2023-09-21 13:07:55 +02:00
Robin d56a0235af v0.0.265 add ListWithCount
Build Docker and Deploy / Run goext test-suite (push) Successful in 49s
2023-09-18 12:57:27 +02:00
Robin de2ca763c1 add function for ListWithCount 2023-09-18 12:56:56 +02:00
Mikescher da52bb5c90 v0.0.264 added Valid() to id-gen
Build Docker and Deploy / Run goext test-suite (push) Successful in 49s
2023-09-18 11:46:17 +02:00
Mikescher 3d4afe7b25 v0.0.263 re-add checksum guard to id-generate
Build Docker and Deploy / Run goext test-suite (push) Failing after 50s
2023-09-18 10:43:29 +02:00
Mikescher f5766d639c v0.0.262 ignore _gen files in bfcodegen checksum-calc
Build Docker and Deploy / Run goext test-suite (push) Successful in 46s
2023-09-18 10:42:43 +02:00
Mikescher cdf2a6e76b v0.0.261 added id-generate.go
Build Docker and Deploy / Run goext test-suite (push) Successful in 1m7s
2023-09-18 10:38:25 +02:00
Robin 6d7cfb86f8 v0.0.260 wmo: fix endless recursion in wmo reflection
Build Docker and Deploy / Run goext test-suite (push) Successful in 50s
2023-09-12 11:40:39 +02:00
Robin 1e9d663ffe fix endless recursion in wmo reflection 2023-09-12 11:39:51 +02:00
Robin 5b8d7ebf87 v0.0.259 wmo: allow fields to pointers to structs
Build Docker and Deploy / Run goext test-suite (push) Failing after 49s
2023-09-12 10:48:57 +02:00
Robin 11dc6d2640 use type instead of value for Reflection in Coll.initFields 2023-09-12 10:47:41 +02:00
Julian 29a3f73f15 v0.0.258
Build Docker and Deploy / Run goext test-suite (push) Failing after 1m22s
2023-09-11 11:28:34 +02:00
Julian 98105642fc removed default sort 2023-09-11 11:28:26 +02:00
Julian 0fd5f3b417 v0.0.257 better handling if pagination is faulty in wmo list
Build Docker and Deploy / Run goext test-suite (push) Successful in 1m2s
2023-09-05 15:01:55 +02:00
Julian 43cac4b3bb v0.0.256 bind header
Build Docker and Deploy / Run goext test-suite (push) Successful in 1m4s
2023-08-28 10:44:38 +02:00
Mikescher cd68af8e66 v0.0.255 tuples
Build Docker and Deploy / Run goext test-suite (push) Failing after 2m43s
2023-08-24 09:47:32 +02:00
Mikescher 113d838876 v0.0.254 revert back to 0.0.250
Build Docker and Deploy / Run goext test-suite (push) Successful in 1m14s
2023-08-22 10:49:57 +02:00
Mikescher 9e5bc0d3ea v0.0.253
Build Docker and Deploy / Run goext test-suite (push) Failing after 1m23s
2023-08-22 10:36:35 +02:00
Mikescher 6d3bd13f61 v0.0.252
Build Docker and Deploy / Run goext test-suite (push) Failing after 1m5s
2023-08-22 10:23:04 +02:00
Mikescher b5ca475b3f v0.0.251 exerr.WithStackSkip
Build Docker and Deploy / Run goext test-suite (push) Successful in 1m9s
2023-08-22 10:21:13 +02:00
Mikescher a75b1291cb v0.0.250
Build Docker and Deploy / Run goext test-suite (push) Failing after 51s
2023-08-21 15:34:27 +02:00
Mikescher 21cd1ee066 v0.0.249 better MDTAny json serialization
Build Docker and Deploy / Run goext test-suite (push) Failing after 54s
2023-08-21 15:19:40 +02:00
Mikescher ae43cbb623 v0.0.248 exerr in wmo package
Build Docker and Deploy / Run goext test-suite (push) Failing after 55s
2023-08-21 15:08:35 +02:00
Mikescher 9b752a911c v0.0.247 -.-
Build Docker and Deploy / Run goext test-suite (push) Failing after 1m12s
2023-08-21 14:23:44 +02:00
Mikescher ec9ac26a4c v0.0.246 timeext
Build Docker and Deploy / Run goext test-suite (push) Successful in 1m11s
2023-08-21 14:15:06 +02:00
Mikescher 39a0b73d56 v0.0.245
Build Docker and Deploy / Run goext test-suite (push) Successful in 40s
2023-08-21 13:27:36 +02:00
Mikescher 2e2e15d4d2 v0.0.244
Build Docker and Deploy / Run goext test-suite (push) Successful in 48s
2023-08-18 13:27:02 +02:00
Mikescher 0d16946aba v0.0.243
Build Docker and Deploy / Run goext test-suite (push) Failing after 1m18s
2023-08-18 13:25:18 +02:00
Mikescher 14441c2378 Adde gitea workflow: tests
Build Docker and Deploy / Run goext test-suite (push) Successful in 57s
2023-08-14 18:39:22 +02:00
Mikescher f6bcdc9903 Merge remote-tracking branch 'origin/master' 2023-08-14 16:33:03 +02:00
Mikescher a95053211c Fix tests 2023-08-14 16:32:39 +02:00
Julian 813ce71e3e v0.0.242 forgot to return something 2023-08-14 16:05:12 +02:00
Julian 56ae0cfc6c v0.0.241 join string array 2023-08-14 15:54:50 +02:00
Mikescher 202afc9068 v0.0.240 2023-08-14 15:36:12 +02:00
Mikescher 56094b3cb6 v0.0.239 pctx.WithTImeout 2023-08-11 16:32:34 +02:00
Mikescher 0da098e9f9 v0.0.238 2023-08-09 19:51:41 +02:00
Mikescher f0881c9fd6 v0.0.237 parse application/x-www-form-urlencoded in ginext 2023-08-09 19:35:01 +02:00
Mikescher 029b408749 v0.0.236 cmdext.FailOnStdErr 2023-08-09 17:48:06 +02:00
Mikescher 84b2be3169 v0.0.235 added .Enum(..) to exerr 2023-08-09 14:40:16 +02:00
Mikescher c872cecc67 v0.0.234 2023-08-09 10:39:14 +02:00
Mikescher 99cd92729e v0.0.233 IncludeMetaInGinOutput 2023-08-09 10:37:59 +02:00
Mikescher ac416f7b69 v0.0.232 2023-08-08 18:01:00 +02:00
Mikescher e10140e143 v0.0.231 2023-08-08 16:10:31 +02:00
Mikescher e165f0f62f v0.0.230 2023-08-08 16:09:02 +02:00
Mikescher 655d4daad9 v0.0.229 2023-08-08 16:05:44 +02:00
Mikescher 87a004e577 v0.0.228 bf 2023-08-08 15:33:52 +02:00
Mikescher 376c6cab50 v0.0.227 error on duplicate exerr.ErrorType 2023-08-08 15:28:29 +02:00
Mikescher 4a3f25baa0 v0.0.226 2023-08-08 14:28:09 +02:00
Mikescher aa33bc8df3 v0.0.225 2023-08-08 13:09:15 +02:00
Mikescher 96b3718375 v0.0.224 implement error.As(x) for exerr 2023-08-08 12:38:22 +02:00
Mikescher 5f9b55933b v0.0.223 2023-08-08 11:52:40 +02:00
Julian 74d42637e7 v0.0.222 forgot status code 2023-08-06 19:11:59 +02:00
Julian 0c05bcf29b v0.0.221 download file data 2023-08-06 19:10:31 +02:00
Mikescher 9136143f2f v0.0.220 add ginext.bufferBody 2023-08-03 09:09:27 +02:00
Mikescher 2f1b784dc2 v0.0.219 implement error.Is(*) for exerr 2023-07-28 15:42:12 +02:00
Mikescher 190584e0e6 v0.0.218 bf 2023-07-27 17:16:30 +02:00
Mikescher b7003b9ec9 v0.0.217 2023-07-27 17:12:41 +02:00
Mikescher 4f871271e8 v0.0.216 2023-07-27 17:00:53 +02:00
Mikescher 91f4793678 v0.0.215 Add (ee *ExErr) ToAPIJson 2023-07-27 14:37:11 +02:00
Julian 3b30bb049e v0.0.214 reassign innerctx 2023-07-27 09:58:10 +02:00
Julian f0c5b36ea9 v0.0.213 inject gin key value pairs into context 2023-07-27 09:46:06 +02:00
Mikescher 647ec64c3b v0.0.212 2023-07-26 10:44:26 +02:00
Mikescher b5f9b6b638 v0.0.211 2023-07-26 10:40:42 +02:00
Mikescher c7949febf2 v0.0.210 fix ginext route dump 2023-07-25 11:16:11 +02:00
Julian 15a4b2a713 v0.0.209 removed g context from err func 2023-07-25 10:56:03 +02:00
Julian 493c6ebae8 v0.0.208 remove context from err functions because its not used 2023-07-25 10:51:14 +02:00
Julian fb847b03af v0.0.207 renamed APIError to Error 2023-07-25 10:47:00 +02:00
Mikescher f826633e6e v0.0.206 2023-07-24 18:50:14 +02:00
Mikescher edeae23bf1 v0.0.205 2023-07-24 18:47:48 +02:00
Mikescher a038b86147 v0.0.204 2023-07-24 18:42:33 +02:00
Mikescher ede0b99d3a v0.0.203 2023-07-24 18:38:04 +02:00
Mikescher d04ce18eb0 v0.0.202 2023-07-24 18:34:56 +02:00
Mikescher 8ae9a0f107 v0.0.201 2023-07-24 18:22:36 +02:00
Mikescher a259bb6dbc v0.0.200 2023-07-24 17:42:18 +02:00
Mikescher adf32568ee v0.0.199 2023-07-24 17:23:38 +02:00
Mikescher 0cfa159cb1 v0.0.198 2023-07-24 14:16:02 +02:00
Mikescher 0ead99608a v0.0.197 2023-07-24 12:27:06 +02:00
Mikescher 7fe3e66cad v0.0.196 2023-07-24 11:47:47 +02:00
Mikescher a73d7d1654 v0.0.195 2023-07-24 11:42:52 +02:00
Mikescher bbd7a7bc2c v0.0.194 2023-07-24 11:40:47 +02:00
Mikescher f5151eb214 v0.0.193 2023-07-24 11:38:57 +02:00
Mikescher eefb9ac9f5 v0.0.192 2023-07-24 11:30:07 +02:00
Mikescher 468a7d212d v0.0.191 2023-07-24 11:18:25 +02:00
Mikescher a4def75d06 v0.0.190 2023-07-24 11:16:57 +02:00
Mikescher 16c66ee28c v0.0.189 2023-07-24 11:11:15 +02:00
Mikescher 2e6ca48d22 v0.0.188 exerr MVP 2023-07-24 10:42:39 +02:00
Julian b1d6509294 v0.0.187 forget to use function 2023-07-24 09:16:37 +02:00
Julian e909d656d9 v0.0.186 convert array to interface arr 2023-07-24 09:13:19 +02:00
Mikescher 0971f60c30 v0.0.185 add Meta() to enums 2023-07-19 19:34:39 +02:00
Mikescher d8270e53ed v0.0.184 re-add missing array methods from merge commit 56684b2c0b 2023-07-19 19:29:59 +02:00
Mikescher 1ee127937a v0.0.183 2023-07-19 19:24:58 +02:00
Mikescher 56684b2c0b exerr [WIP]
(cherry picked from commit c0443af63b)
2023-07-19 19:24:43 +02:00
Julian 1ea6695f82 v0.0.182 added optional commit message 2023-07-19 11:26:23 +02:00
Julian 5273ff7600 v0.0.181 2023-07-19 11:26:06 +02:00
Julian caa69c3629 v0.0.180 Super Test 2023-07-19 11:24:11 +02:00
Julian 0ff5f0aa28 v0.0.179 TestTest 2023-07-19 11:22:25 +02:00
Julian d5cb1e48ed v0.0.178 2023-07-19 11:20:35 +02:00
Mikescher 1c2d3f541f v0.0.177 2023-07-18 16:08:24 +02:00
Mikescher ec62ad436f v0.0.176 2023-07-18 16:01:34 +02:00
Mikescher 8d0ef0f002 v0.0.175 2023-07-18 15:59:12 +02:00
Mikescher d78550672e v0.0.174 2023-07-18 15:23:32 +02:00
Mikescher 1d629f6db8 v0.0.173 2023-07-18 15:12:06 +02:00
Mikescher f7d291056d v0.0.172 2023-07-18 14:40:10 +02:00
Mikescher 710c257c64 v0.0.171 2023-07-18 13:34:54 +02:00
Julian c320bb3d90 v0.0.170 2023-07-17 12:42:49 +02:00
Mikescher 2f01a1d50f v0.0.169 2023-07-05 19:27:49 +02:00
Mikescher ffc57b7e89 v0.0.168 2023-07-05 19:27:15 +02:00
Mikescher d88cd3c22b v0.0.167 2023-06-22 17:33:56 +02:00
Mikescher ac5ad640bd v0.0.166 2023-06-22 15:07:06 +02:00
Mikescher 21d241f9b1 v0.0.163 2023-06-18 01:16:52 +02:00
Mikescher 2569c165f8 v0.0.162 2023-06-11 16:38:47 +02:00
Mikescher ee262a94fb v0.0.161 2023-06-11 16:35:20 +02:00
Mikescher 7977c0e59c Added rfctime.Date type 2023-06-10 19:13:15 +02:00
Mikescher ceff0161c6 v0.0.159 2023-06-10 18:35:56 +02:00
Mikescher a30da61419 v0.0.158 2023-06-10 16:28:50 +02:00
Mikescher b613b122e3 v0.0.157 2023-06-10 16:22:14 +02:00
Mikescher d017530444 v0.0.156 2023-06-10 00:19:17 +02:00
Mikescher 8de83cc290 v0.0.155 2023-06-08 16:26:06 +02:00
Mikescher 603ec82b83 v0.0.154 2023-06-08 16:24:53 +02:00
Mikescher 93c4cf31a8 v0.0.153 2023-06-08 16:24:15 +02:00
Mikescher dc2d8a9103 v0.0.152 2023-06-08 16:17:01 +02:00
Mikescher 6589e8d5cd v0.0.151 2023-06-07 17:57:03 +02:00
Mikescher 0006c6859d v0.0.150 2023-06-07 17:48:36 +02:00
Mikescher 827b3fc1b7 v0.0.149 2023-06-07 17:45:45 +02:00
Mikescher f7dce4a102 v0.0.148 2023-06-07 17:22:38 +02:00
381 changed files with 44243 additions and 3779 deletions
+44
View File
@@ -0,0 +1,44 @@
{
"permissions": {
"allow": [
"Bash(make:*)",
"Bash(mkdir:*)",
"Bash(go build:*)",
"Bash(go test:*)",
"Bash(go get:*)",
"Bash(go mod:*)",
"Bash(go clean:*)",
"Bash(go doc:*)",
"Bash(grep:*)",
"Bash(find:*)",
"Bash(rg:*)",
"Bash(base64:*)",
"Bash(sed:*)",
"Bash(ls:*)",
"Bash(curl:*)",
"Bash(timeout 60s go test -v -count=1 ./...)",
"Bash(timeout 60s go test -v -count=1 ./tests/integration/...)",
"Bash(timeout 60s go test:*)",
"Bash(timeout 300 make test)",
"Bash(timeout 30s go test ./tests/integration -run:*)",
"Bash(done)",
"Bash(awk:*)",
"WebFetch(domain:platform.openai.com)"
],
"deny": [
],
"defaultMode": "acceptEdits"
},
"env": {
"CLAUDE_CODE_ENABLE_TELEMETRY": "0",
"DISABLE_ERROR_REPORTING": "1",
"DISABLE_TELEMETRY": "1"
}
}
+42
View File
@@ -0,0 +1,42 @@
# https://docs.gitea.com/next/usage/actions/quickstart
# https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions
# https://docs.github.com/en/actions/learn-github-actions/contexts#github-context
name: Build Docker and Deploy
run-name: "[test]: ${{ github.event.head_commit.message }}"
on:
push:
branches:
- '*'
- '**'
jobs:
run_tests:
name: Run goext test-suite
runs-on: bfb-cicd-latest
steps:
- name: Check out code
uses: actions/checkout@v3
- name: Setup go
uses: actions/setup-go@v4
with:
go-version-file: '${{ gitea.workspace }}/go.mod'
- name: Setup packages
uses: awalsh128/cache-apt-pkgs-action@latest
with:
packages: curl python3
version: 1.0
- name: go version
run: go version
- name: Run tests
run: cd "${{ gitea.workspace }}" && make test
+2
View File
@@ -1,4 +1,6 @@
.claude-queue
##########################################################################
.idea/**/workspace.xml
+2
View File
@@ -6,3 +6,5 @@
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
# GitHub Copilot persisted chat sessions
/copilot/chatSessions
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AgentMigrationStateService">
<option name="migrationStatus" value="COMPLETED" />
</component>
</project>
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AskMigrationStateService">
<option name="migrationStatus" value="COMPLETED" />
</component>
</project>
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Ask2AgentMigrationStateService">
<option name="migrationStatus" value="COMPLETED" />
</component>
</project>
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="EditMigrationStateService">
<option name="migrationStatus" value="COMPLETED" />
</component>
</project>
+11
View File
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GoImports">
<option name="excludedPackages">
<array>
<option value="github.com/pkg/errors" />
<option value="golang.org/x/net/context" />
</array>
</option>
</component>
</project>
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GoLinterSettings">
<option name="checkGoLinterExe" value="false" />
</component>
</project>
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="SqlDialectMappings">
<file url="file://$PROJECT_DIR$/sq/sq_test.go" dialect="SQLite" />
</component>
</project>
+7 -1
View File
@@ -5,7 +5,13 @@ run:
test:
# go test ./...
which gotestsum || go install gotest.tools/gotestsum@latest
gotestsum --format "testname" -- -tags="timetzdata sqlite_fts5 sqlite_foreign_keys" "./test"
gotestsum --format "testname" -- -tags="timetzdata sqlite_fts5 sqlite_foreign_keys" "./..."
test-in-docker:
tag="goext_temp_test_image:$(shell uuidgen | tr -d '-')"; \
docker build --tag $$tag . -f .gitea/workflows/Dockerfile_tests; \
docker run --rm $$tag; \
docker rmi $$tag
version:
_data/version.sh
+105 -31
View File
@@ -5,37 +5,111 @@ A collection of general & useful library methods
This should not have any heavy dependencies (gin, mongo, etc) and add missing basic language features...
Potentially needs `export GOPRIVATE="gogs.mikescher.com"`
Potentially needs `export GOPRIVATE="git.blackforestbytes.com"`
### Packages:
## Packages:
| Name | Maintainer | Description |
|--------------|------------|---------------------------------------------------------------------------------------------------------------|
| langext | Mike | General uttility/helper functions, (everything thats missing from go standard library) |
| mathext | Mike | Utility/Helper functions for math |
| cryptext | Mike | Utility/Helper functions for encryption |
| syncext | Mike | Utility/Helper funtions for multi-threading / mutex / channels |
| dataext | Mike | Various useful data structures |
| zipext | Mike | Utility for zip/gzip/tar etc |
| reflectext | Mike | Utility for golagn reflection |
| | | |
| mongoext | Mike | Utility/Helper functions for mongodb |
| cursortoken | Mike | MongoDB cursortoken implementation |
| | | |
| totpext | Mike | Implementation of TOTP (2-Factor-Auth) |
| termext | Mike | Utilities for terminals (mostly color output) |
| confext | Mike | Parses environment configuration into structs |
| cmdext | Mike | Runner for external commands/processes |
| | | |
| sq | Mike | Utility functions for sql based databases |
| tst | Mike | Utility functions for unit tests |
| | | |
| rfctime | Mike | Classes for time seriallization, with different marshallign method for mongo and json |
| gojson | Mike | Same interface for marshalling/unmarshalling as go/json, except with proper serialization of null arrays/maps |
| | | |
| bfcodegen | Mike | Various codegen tools (run via go generate) |
| | | |
| rext | Mike | Regex Wrapper, wraps regexp with a better interface |
| wmo | Mike | Mongo Wrapper, wraps mongodb with a better interface |
| | | |
| Name | Maintainer | Description |
|-------------|------------|---------------------------------------------------------------------------------------------------------------|
| langext | Mike | General uttility/helper functions, (everything thats missing from go standard library) |
| mathext | Mike | Utility/Helper functions for math |
| cryptext | Mike | Utility/Helper functions for encryption |
| syncext | Mike | Utility/Helper funtions for multi-threading / mutex / channels |
| dataext | Mike | Various useful data structures |
| zipext | Mike | Utility for zip/gzip/tar etc |
| reflectext | Mike | Utility for golang reflection |
| fsext | Mike | Utility for filesytem access |
| ctxext | Mike | Utility for context.Context |
| | | |
| mongoext | Mike | Utility/Helper functions for mongodb (kinda abandoned) |
| cursortoken | Mike | MongoDB cursortoken implementation |
| pagination | Mike | Pagination implementation |
| | | |
| ginext | Mike | gin wrapper |
| wsw | Mike | websocket wrapper |
| | | |
| totpext | Mike | Implementation of TOTP (2-Factor-Auth) |
| termext | Mike | Utilities for terminals (mostly color output) |
| confext | Mike | Parses environment configuration into structs |
| cmdext | Mike | Runner for external commands/processes |
| excelext | Mike | Build Excel files |
| | | |
| sq | Mike | Utility functions for sql based databases (primarily sqlite) |
| tst | Mike | Utility functions for unit tests |
| | | |
| rfctime | Mike | Classes for time seriallization, with different marshallign method for mongo and json |
| gojson | Mike | Same interface for marshalling/unmarshalling as go/json, except with proper serialization of null arrays/maps |
| | | |
| bfcodegen | Mike | Various codegen tools (run via go generate) |
| | | |
| rext | Mike | Regex Wrapper, wraps regexp with a better interface |
| wmo | Mike | Mongo Wrapper, wraps mongodb with a better interface |
| | | |
| scn | Mike | SimpleCloudNotifier |
| | | |
## Usage:
### exerr
- see **mongoext/builder.go** for full info
Short summary:
- An better error package with metadata, listener, api-output and error-traces
- Initialize with `exerr.Init()`
- *Never* return `err` direct, always use exerr.Wrap(err, "...") - add metadata where applicable
- at the end either Print(), Fatal() or Output() your error (print = stdout, fatal = panic, output = json API response)
- You can add listeners with exerr.RegisterListener(), and save the full errors to a db or smth
### wmo
- A typed wrapper around the official mongo-go-driver
- Use `wmo.W[...](...)` to wrap the collections and type-ify them
- The new collections have all the usual methods, but types
- Also they have List() and Paginate() methods for paginated listings (witehr with a cursortoken or page/limit)
- Register additional hooks with `WithDecodeFunc`, `WithUnmarshalHook`, `WithMarshalHook`, `WithModifyingPipeline`, `WithModifyingPipelineFunc`
- List(), Paginate(), etc support filter interfaces
- Rule(s) of thumb:
- filter the results in the filter interface
- sort the results in the sort function of the filter interface
- add joins ($lookup's) in the `WithModifyingPipelineFunc`/`WithModifyingPipeline`
#### ginext
- A wrapper around gin-gonic/gin
- create the gin engine with `ginext.NewEngine`
- Add routes with `engine.Routes()...`
- `.Use(..)` adds a middleware
- `.Group(..)` adds a group
- `.Get().Handle(..)` adds a handler
- Handler return values (in contract to ginext) - values implement the `ginext.HTTPResponse` interface
- Every handler starts with something like:
```go
func (handler Handler) CommunityMetricsValues(pctx ginext.PreContext) ginext.HTTPResponse {
type communityURI struct {
Version string `uri:"version"`
CommunityID models.CommunityID `uri:"cid"`
}
type body struct {
UserID models.UserID `json:"userID"`
EventID models.EventID `json:"eventID"`
}
var u uri
var b body
ctx, gctx, httpErr := pctx.URI(&u).Body(&b).Start() // can have more unmarshaller, like header, form, etc
if httpErr != nil {
return *httpErr
}
defer ctx.Cancel()
// do stuff
}
```
#### sq
- TODO (like mongoext for sqlite/sql databases)
+4 -8
View File
@@ -2,12 +2,8 @@
- cronext
- cursortoken
- rfctime.HMSTimeOnly
- rfctime.NanoTimeOnly
- typed/geenric mongo wrapper
- error package
- rfctime.DateOnly
- rfctime.HMSTimeOnly
- rfctime.NanoTimeOnly
- remove sqlx dependency from sq (unmaintained, and mostly superseeded by our own stuff?)
- Move DBLogger and DBPreprocessor to sq
+10
View File
@@ -21,6 +21,11 @@ if [ "$( git rev-parse --abbrev-ref HEAD )" != "master" ]; then
exit 1
fi
echo ""
echo -n "Insert optional commit message: "
read commitMessage
echo ""
git pull --ff
go get -u ./...
@@ -40,6 +45,11 @@ git add --verbose .
msg="v${next_ver}"
if [[ "$commitMessage" != "" ]]; then
msg="${msg} ${commitMessage}"
fi
if [ $# -gt 0 ]; then
msg="$1"
fi
Binary file not shown.
Binary file not shown.
+198
View File
@@ -0,0 +1,198 @@
package bfcodegen
import (
"bytes"
_ "embed"
"errors"
"fmt"
"git.blackforestbytes.com/BlackForestBytes/goext"
"git.blackforestbytes.com/BlackForestBytes/goext/cryptext"
"git.blackforestbytes.com/BlackForestBytes/goext/langext"
"git.blackforestbytes.com/BlackForestBytes/goext/rext"
"go/format"
"io"
"os"
"path"
"path/filepath"
"regexp"
"strings"
"text/template"
)
type CSIDDef struct {
File string
FileRelative string
Name string
Prefix string
}
type CSIDGenOptions struct {
DebugOutput *bool
}
var rexCSIDPackage = rext.W(regexp.MustCompile(`^package\s+(?P<name>[A-Za-z0-9_]+)\s*$`))
var rexCSIDDef = rext.W(regexp.MustCompile(`^\s*type\s+(?P<name>[A-Za-z0-9_]+)\s+string\s*//\s*(@csid:type)\s+\[(?P<prefix>[A-Z0-9]{3})].*$`))
var rexCSIDChecksumConst = rext.W(regexp.MustCompile(`const ChecksumCharsetIDGenerator = "(?P<cs>[A-Za-z0-9_]*)"`))
//go:embed csid-generate.template
var templateCSIDGenerateText string
func GenerateCharsetIDSpecs(sourceDir string, destFile string, opt CSIDGenOptions) error {
debugOutput := langext.Coalesce(opt.DebugOutput, false)
files, err := os.ReadDir(sourceDir)
if err != nil {
return err
}
oldChecksum := "N/A"
if _, err := os.Stat(destFile); !os.IsNotExist(err) {
content, err := os.ReadFile(destFile)
if err != nil {
return err
}
if m, ok := rexCSIDChecksumConst.MatchFirst(string(content)); ok {
oldChecksum = m.GroupByName("cs").Value()
}
}
files = langext.ArrFilter(files, func(v os.DirEntry) bool { return v.Name() != path.Base(destFile) })
files = langext.ArrFilter(files, func(v os.DirEntry) bool { return strings.HasSuffix(v.Name(), ".go") })
files = langext.ArrFilter(files, func(v os.DirEntry) bool { return !strings.HasSuffix(v.Name(), "_gen.go") })
langext.SortBy(files, func(v os.DirEntry) string { return v.Name() })
var newChecksumStr strings.Builder
newChecksumStr.WriteString(goext.GoextVersion)
for _, f := range files {
content, err := os.ReadFile(path.Join(sourceDir, f.Name()))
if err != nil {
return err
}
newChecksumStr.WriteString("\n" + f.Name() + "\t" + cryptext.BytesSha256(content))
}
newChecksum := cryptext.BytesSha256([]byte(newChecksumStr.String()))
if newChecksum != oldChecksum {
fmt.Printf("[CSIDGenerate] Checksum has changed ( %s -> %s ), will generate new file\n\n", oldChecksum, newChecksum)
} else {
fmt.Printf("[CSIDGenerate] Checksum unchanged ( %s ), nothing to do\n", oldChecksum)
return nil
}
allIDs := make([]CSIDDef, 0)
pkgname := ""
for _, f := range files {
if debugOutput {
fmt.Printf("========= %s =========\n\n", f.Name())
}
fileIDs, pn, err := processCSIDFile(sourceDir, path.Join(sourceDir, f.Name()), debugOutput)
if err != nil {
return err
}
if debugOutput {
fmt.Printf("\n")
}
allIDs = append(allIDs, fileIDs...)
if pn != "" {
pkgname = pn
}
}
if pkgname == "" {
return errors.New("no package name found in any file")
}
fdata, err := format.Source([]byte(fmtCSIDOutput(newChecksum, allIDs, pkgname)))
if err != nil {
return err
}
err = os.WriteFile(destFile, fdata, 0o755)
if err != nil {
return err
}
return nil
}
func processCSIDFile(basedir string, fn string, debugOutput bool) ([]CSIDDef, string, error) {
file, err := os.Open(fn)
if err != nil {
return nil, "", err
}
defer func() { _ = file.Close() }()
bin, err := io.ReadAll(file)
if err != nil {
return nil, "", err
}
lines := strings.Split(string(bin), "\n")
ids := make([]CSIDDef, 0)
pkgname := ""
for i, line := range lines {
if i == 0 && strings.HasPrefix(line, "// Code generated by") {
break
}
if match, ok := rexCSIDPackage.MatchFirst(line); i == 0 && ok {
pkgname = match.GroupByName("name").Value()
continue
}
if match, ok := rexCSIDDef.MatchFirst(line); ok {
rfp, err := filepath.Rel(basedir, fn)
if err != nil {
return nil, "", err
}
def := CSIDDef{
File: fn,
FileRelative: rfp,
Name: match.GroupByName("name").Value(),
Prefix: match.GroupByName("prefix").Value(),
}
if debugOutput {
fmt.Printf("Found ID definition { '%s' }\n", def.Name)
}
ids = append(ids, def)
}
}
return ids, pkgname, nil
}
func fmtCSIDOutput(cs string, ids []CSIDDef, pkgname string) string {
templ := template.Must(template.New("csid-generate").Parse(templateCSIDGenerateText))
buffer := bytes.Buffer{}
err := templ.Execute(&buffer, langext.H{
"PkgName": pkgname,
"Checksum": cs,
"GoextVersion": goext.GoextVersion,
"IDs": ids,
})
if err != nil {
panic(err)
}
return buffer.String()
}
+194
View File
@@ -0,0 +1,194 @@
// Code generated by csid-generate.go DO NOT EDIT.
package {{.PkgName}}
import "crypto/rand"
import "crypto/sha256"
import "fmt"
import "github.com/go-playground/validator/v10"
import "github.com/rs/zerolog/log"
import "git.blackforestbytes.com/BlackForestBytes/goext/exerr"
import "git.blackforestbytes.com/BlackForestBytes/goext/langext"
import "git.blackforestbytes.com/BlackForestBytes/goext/rext"
import "math/big"
import "reflect"
import "regexp"
import "strings"
const ChecksumCharsetIDGenerator = "{{.Checksum}}" // GoExtVersion: {{.GoextVersion}}
const idlen = 24
const checklen = 1
const idCharset = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
const idCharsetLen = len(idCharset)
var charSetReverseMap = generateCharsetMap()
const ({{range .IDs}}
prefix{{.Name}} = "{{.Prefix}}" {{end}}
)
var ({{range .IDs}}
regex{{.Name}} = generateRegex(prefix{{.Name}}) {{end}}
)
func generateRegex(prefix string) rext.Regex {
return rext.W(regexp.MustCompile(fmt.Sprintf("^%s[%s]{%d}[%s]{%d}$", prefix, idCharset, idlen-len(prefix)-checklen, idCharset, checklen)))
}
func generateCharsetMap() []int {
result := make([]int, 128)
for i := 0; i < len(result); i++ {
result[i] = -1
}
for idx, chr := range idCharset {
result[int(chr)] = idx
}
return result
}
func generateID(prefix string) string {
k := ""
csMax := big.NewInt(int64(idCharsetLen))
checksum := 0
for i := 0; i < idlen-len(prefix)-checklen; i++ {
v, err := rand.Int(rand.Reader, csMax)
if err != nil {
panic(err)
}
v64 := v.Int64()
k += string(idCharset[v64])
checksum = (checksum + int(v64)) % (idCharsetLen)
}
checkstr := string(idCharset[checksum%idCharsetLen])
return prefix + k + checkstr
}
func generateIDFromSeed(prefix string, seed string) string {
h := sha256.New()
iddata := ""
for len(iddata) < idlen-len(prefix)-checklen {
h.Write([]byte(seed))
bs := h.Sum(nil)
iddata += langext.NewAnyBaseConverter(idCharset).Encode(bs)
}
checksum := 0
for i := 0; i < idlen-len(prefix)-checklen; i++ {
ichr := int(iddata[i])
checksum = (checksum + charSetReverseMap[ichr]) % (idCharsetLen)
}
checkstr := string(idCharset[checksum%idCharsetLen])
return prefix + iddata[:(idlen-len(prefix)-checklen)] + checkstr
}
func validateID(prefix string, value string) error {
if len(value) != idlen {
return exerr.New(exerr.TypeInvalidCSID, "id has the wrong length").Str("value", value).Build()
}
if !strings.HasPrefix(value, prefix) {
return exerr.New(exerr.TypeInvalidCSID, "id is missing the correct prefix").Str("value", value).Str("prefix", prefix).Build()
}
checksum := 0
for i := len(prefix); i < len(value)-checklen; i++ {
ichr := int(value[i])
if ichr < 0 || ichr >= len(charSetReverseMap) || charSetReverseMap[ichr] == -1 {
return exerr.New(exerr.TypeInvalidCSID, "id contains invalid characters").Str("value", value).Build()
}
checksum = (checksum + charSetReverseMap[ichr]) % (idCharsetLen)
}
checkstr := string(idCharset[checksum%idCharsetLen])
if !strings.HasSuffix(value, checkstr) {
return exerr.New(exerr.TypeInvalidCSID, "id checkstring is invalid").Str("value", value).Str("checkstr", checkstr).Build()
}
return nil
}
func getRawData(prefix string, value string) string {
if len(value) != idlen {
return ""
}
return value[len(prefix) : idlen-checklen]
}
func getCheckString(prefix string, value string) string {
if len(value) != idlen {
return ""
}
return value[idlen-checklen:]
}
func ValidateEntityID(vfl validator.FieldLevel) bool {
if !vfl.Field().CanInterface() {
log.Error().Msgf("Failed to validate EntityID (cannot interface ?!?)")
return false
}
ifvalue := vfl.Field().Interface()
if value1, ok := ifvalue.(EntityID); ok {
if vfl.Field().Type().Kind() == reflect.Pointer && langext.IsNil(value1) {
return true
}
if err := value1.Valid(); err != nil {
log.Debug().Msgf("Failed to validate EntityID '%s' (%s)", value1.String(), err.Error())
return false
} else {
return true
}
} else {
log.Error().Msgf("Failed to validate EntityID (wrong type: %T)", ifvalue)
return false
}
}
{{range .IDs}}
// ================================ {{.Name}} ({{.FileRelative}}) ================================
func New{{.Name}}() {{.Name}} {
return {{.Name}}(generateID(prefix{{.Name}}))
}
func (id {{.Name}}) Valid() error {
return validateID(prefix{{.Name}}, string(id))
}
func (i {{.Name}}) String() string {
return string(i)
}
func (i {{.Name}}) Prefix() string {
return prefix{{.Name}}
}
func (id {{.Name}}) Raw() string {
return getRawData(prefix{{.Name}}, string(id))
}
func (id {{.Name}}) CheckString() string {
return getCheckString(prefix{{.Name}}, string(id))
}
func (id {{.Name}}) IsZero() bool {
return id == ""
}
func (id {{.Name}}) Regex() rext.Regex {
return regex{{.Name}}
}
{{end}}
+52
View File
@@ -0,0 +1,52 @@
package bfcodegen
import (
_ "embed"
"fmt"
"git.blackforestbytes.com/BlackForestBytes/goext/cmdext"
"git.blackforestbytes.com/BlackForestBytes/goext/langext"
"git.blackforestbytes.com/BlackForestBytes/goext/tst"
"os"
"path/filepath"
"testing"
"time"
)
//go:embed _test_example_1.tgz
var CSIDExampleModels1 []byte
func TestGenerateCSIDSpecs(t *testing.T) {
tmpFile := filepath.Join(t.TempDir(), langext.MustHexUUID()+".tgz")
tmpDir := filepath.Join(t.TempDir(), langext.MustHexUUID())
err := os.WriteFile(tmpFile, CSIDExampleModels1, 0o777)
tst.AssertNoErr(t, err)
t.Cleanup(func() { _ = os.Remove(tmpFile) })
err = os.Mkdir(tmpDir, 0o777)
tst.AssertNoErr(t, err)
t.Cleanup(func() { _ = os.RemoveAll(tmpFile) })
_, err = cmdext.Runner("tar").Arg("-xvzf").Arg(tmpFile).Arg("-C").Arg(tmpDir).FailOnExitCode().FailOnTimeout().Timeout(time.Minute).Run()
tst.AssertNoErr(t, err)
err = GenerateCharsetIDSpecs(tmpDir, tmpDir+"/csid_gen.go", CSIDGenOptions{DebugOutput: langext.PTrue})
tst.AssertNoErr(t, err)
err = GenerateCharsetIDSpecs(tmpDir, tmpDir+"/csid_gen.go", CSIDGenOptions{DebugOutput: langext.PTrue})
tst.AssertNoErr(t, err)
fmt.Println()
fmt.Println()
fmt.Println()
fmt.Println("=====================================================================================================")
fmt.Println(string(tst.Must(os.ReadFile(tmpDir + "/csid_gen.go"))(t)))
fmt.Println("=====================================================================================================")
fmt.Println()
fmt.Println()
fmt.Println()
}
+228 -209
View File
@@ -1,48 +1,59 @@
package bfcodegen
import (
"bytes"
_ "embed"
"encoding/json"
"errors"
"fmt"
"gogs.mikescher.com/BlackForestBytes/goext"
"gogs.mikescher.com/BlackForestBytes/goext/cmdext"
"gogs.mikescher.com/BlackForestBytes/goext/cryptext"
"gogs.mikescher.com/BlackForestBytes/goext/langext"
"gogs.mikescher.com/BlackForestBytes/goext/rext"
"git.blackforestbytes.com/BlackForestBytes/goext"
"git.blackforestbytes.com/BlackForestBytes/goext/cryptext"
"git.blackforestbytes.com/BlackForestBytes/goext/langext"
"git.blackforestbytes.com/BlackForestBytes/goext/rext"
"go/format"
"io"
"os"
"path"
"path/filepath"
"reflect"
"regexp"
"strings"
"time"
"text/template"
)
type EnumDefVal struct {
VarName string
Value string
Description *string
Data *map[string]any
RawComment *string
}
type EnumDef struct {
File string
FileRelative string
EnumTypeName string
Type string
Values []EnumDefVal
}
var rexPackage = rext.W(regexp.MustCompile("^package\\s+(?P<name>[A-Za-z0-9_]+)\\s*$"))
type EnumGenOptions struct {
DebugOutput *bool
GoFormat *bool
}
var rexEnumDef = rext.W(regexp.MustCompile("^\\s*type\\s+(?P<name>[A-Za-z0-9_]+)\\s+(?P<type>[A-Za-z0-9_]+)\\s*//\\s*(@enum:type).*$"))
var rexEnumPackage = rext.W(regexp.MustCompile(`^package\s+(?P<name>[A-Za-z0-9_]+)\s*$`))
var rexValueDef = rext.W(regexp.MustCompile("^\\s*(?P<name>[A-Za-z0-9_]+)\\s+(?P<type>[A-Za-z0-9_]+)\\s*=\\s*(?P<value>(\"[A-Za-z0-9_:]+\"|[0-9]+))\\s*(//(?P<descr>.*))?.*$"))
var rexEnumDef = rext.W(regexp.MustCompile(`^\s*type\s+(?P<name>[A-Za-z0-9_]+)\s+(?P<type>[A-Za-z0-9_]+)\s*//\s*(@enum:type).*$`))
var rexChecksumConst = rext.W(regexp.MustCompile("const ChecksumGenerator = \"(?P<cs>[A-Za-z0-9_]*)\""))
var rexEnumValueDef = rext.W(regexp.MustCompile(`^\s*(?P<name>[A-Za-z0-9_]+)\s+(?P<type>[A-Za-z0-9_]+)\s*=\s*(?P<value>("[/@A-Za-z0-9_:\s\-.]*"|[0-9]+))\s*(//(?P<comm>.*))?.*$`))
func GenerateEnumSpecs(sourceDir string, destFile string) error {
var rexEnumChecksumConst = rext.W(regexp.MustCompile(`const ChecksumEnumGenerator = "(?P<cs>[A-Za-z0-9_]*)"`))
files, err := os.ReadDir(sourceDir)
if err != nil {
return err
}
//go:embed enum-generate.template
var templateEnumGenerateText string
func GenerateEnumSpecs(sourceDir string, destFile string, opt EnumGenOptions) error {
oldChecksum := "N/A"
if _, err := os.Stat(destFile); !os.IsNotExist(err) {
@@ -50,31 +61,57 @@ func GenerateEnumSpecs(sourceDir string, destFile string) error {
if err != nil {
return err
}
if m, ok := rexChecksumConst.MatchFirst(string(content)); ok {
if m, ok := rexEnumChecksumConst.MatchFirst(string(content)); ok {
oldChecksum = m.GroupByName("cs").Value()
}
}
gocode, _, changed, err := _generateEnumSpecs(sourceDir, destFile, oldChecksum, langext.Coalesce(opt.GoFormat, true), langext.Coalesce(opt.DebugOutput, false))
if err != nil {
return err
}
if !changed {
return nil
}
err = os.WriteFile(destFile, []byte(gocode), 0o755)
if err != nil {
return err
}
return nil
}
func _generateEnumSpecs(sourceDir string, destFile string, oldChecksum string, gofmt bool, debugOutput bool) (string, string, bool, error) {
files, err := os.ReadDir(sourceDir)
if err != nil {
return "", "", false, err
}
files = langext.ArrFilter(files, func(v os.DirEntry) bool { return v.Name() != path.Base(destFile) })
files = langext.ArrFilter(files, func(v os.DirEntry) bool { return strings.HasSuffix(v.Name(), ".go") })
files = langext.ArrFilter(files, func(v os.DirEntry) bool { return !strings.HasSuffix(v.Name(), "_gen.go") })
langext.SortBy(files, func(v os.DirEntry) string { return v.Name() })
newChecksumStr := goext.GoextVersion
var newChecksumStr strings.Builder
newChecksumStr.WriteString(goext.GoextVersion)
for _, f := range files {
content, err := os.ReadFile(path.Join(sourceDir, f.Name()))
if err != nil {
return err
return "", "", false, err
}
newChecksumStr += "\n" + f.Name() + "\t" + cryptext.BytesSha256(content)
newChecksumStr.WriteString("\n" + f.Name() + "\t" + cryptext.BytesSha256(content))
}
newChecksum := cryptext.BytesSha256([]byte(newChecksumStr))
newChecksum := cryptext.BytesSha256([]byte(newChecksumStr.String()))
if newChecksum != oldChecksum {
fmt.Printf("[EnumGenerate] Checksum has changed ( %s -> %s ), will generate new file\n\n", oldChecksum, newChecksum)
} else {
fmt.Printf("[EnumGenerate] Checksum unchanged ( %s ), nothing to do\n", oldChecksum)
return nil
return "", oldChecksum, false, nil
}
allEnums := make([]EnumDef, 0)
@@ -82,13 +119,18 @@ func GenerateEnumSpecs(sourceDir string, destFile string) error {
pkgname := ""
for _, f := range files {
fmt.Printf("========= %s =========\n\n", f.Name())
fileEnums, pn, err := processFile(path.Join(sourceDir, f.Name()))
if err != nil {
return err
if debugOutput {
fmt.Printf("========= %s =========\n\n", f.Name())
}
fmt.Printf("\n")
fileEnums, pn, err := processEnumFile(sourceDir, path.Join(sourceDir, f.Name()), debugOutput)
if err != nil {
return "", "", false, err
}
if debugOutput {
fmt.Printf("\n")
}
allEnums = append(allEnums, fileEnums...)
@@ -98,32 +140,24 @@ func GenerateEnumSpecs(sourceDir string, destFile string) error {
}
if pkgname == "" {
return errors.New("no package name found in any file")
return "", "", false, errors.New("no package name found in any file")
}
err = os.WriteFile(destFile, []byte(fmtOutput(newChecksum, allEnums, pkgname)), 0o755)
rdata := fmtEnumOutput(newChecksum, allEnums, pkgname)
if !gofmt {
return rdata, newChecksum, true, nil
}
fdata, err := format.Source([]byte(rdata))
if err != nil {
return err
return "", "", false, err
}
res, err := cmdext.RunCommand("go", []string{"fmt", destFile}, langext.Ptr(2*time.Second))
if err != nil {
return err
}
if res.CommandTimedOut {
fmt.Println(res.StdCombined)
return errors.New("go fmt timed out")
}
if res.ExitCode != 0 {
fmt.Println(res.StdCombined)
return errors.New("go fmt did not succeed")
}
return nil
return string(fdata), newChecksum, true, nil
}
func processFile(fn string) ([]EnumDef, string, error) {
func processEnumFile(basedir string, fn string, debugOutput bool) ([]EnumDef, string, error) {
file, err := os.Open(fn)
if err != nil {
return nil, "", err
@@ -147,28 +181,62 @@ func processFile(fn string) ([]EnumDef, string, error) {
break
}
if match, ok := rexPackage.MatchFirst(line); i == 0 && ok {
if match, ok := rexEnumPackage.MatchFirst(line); i == 0 && ok {
pkgname = match.GroupByName("name").Value()
continue
}
if match, ok := rexEnumDef.MatchFirst(line); ok {
rfp, err := filepath.Rel(basedir, fn)
if err != nil {
return nil, "", err
}
def := EnumDef{
File: fn,
FileRelative: rfp,
EnumTypeName: match.GroupByName("name").Value(),
Type: match.GroupByName("type").Value(),
Values: make([]EnumDefVal, 0),
}
enums = append(enums, def)
fmt.Printf("Found enum definition { '%s' -> '%s' }\n", def.EnumTypeName, def.Type)
if debugOutput {
fmt.Printf("Found enum definition { '%s' -> '%s' }\n", def.EnumTypeName, def.Type)
}
}
if match, ok := rexValueDef.MatchFirst(line); ok {
if match, ok := rexEnumValueDef.MatchFirst(line); ok {
typename := match.GroupByName("type").Value()
comment := match.GroupByNameOrEmpty("comm").ValueOrNil()
var descr *string = nil
var data *map[string]any = nil
if comment != nil {
comment = new(strings.TrimSpace(*comment))
if strings.HasPrefix(*comment, "{") {
if v, ok := tryParseDataComment(*comment); ok {
data = &v
if anyDataDescr, ok := v["description"]; ok {
if dataDescr, ok := anyDataDescr.(string); ok {
descr = &dataDescr
}
}
} else {
descr = comment
}
} else {
descr = comment
}
}
def := EnumDefVal{
VarName: match.GroupByName("name").Value(),
Value: match.GroupByName("value").Value(),
Description: match.GroupByNameOrEmpty("descr").ValueOrNil(),
RawComment: comment,
Description: descr,
Data: data,
}
found := false
@@ -176,16 +244,21 @@ func processFile(fn string) ([]EnumDef, string, error) {
if v.EnumTypeName == typename {
enums[i].Values = append(enums[i].Values, def)
found = true
if def.Description != nil {
fmt.Printf("Found enum value [%s] for '%s' ('%s')\n", def.Value, def.VarName, *def.Description)
} else {
fmt.Printf("Found enum value [%s] for '%s'\n", def.Value, def.VarName)
if debugOutput {
if def.Description != nil {
fmt.Printf("Found enum value [%s] for '%s' ('%s')\n", def.Value, def.VarName, *def.Description)
} else {
fmt.Printf("Found enum value [%s] for '%s'\n", def.Value, def.VarName)
}
}
break
}
}
if !found {
fmt.Printf("Found non-enum value [%s] for '%s' ( looks like enum value, but no matching @enum:type )\n", def.Value, def.VarName)
if debugOutput {
fmt.Printf("Found non-enum value [%s] for '%s' ( looks like enum value, but no matching @enum:type )\n", def.Value, def.VarName)
}
}
}
}
@@ -193,163 +266,109 @@ func processFile(fn string) ([]EnumDef, string, error) {
return enums, pkgname, nil
}
func fmtOutput(cs string, enums []EnumDef, pkgname string) string {
str := "// Code generated by enum-generate.go DO NOT EDIT.\n"
str += "\n"
str += "package " + pkgname + "\n"
str += "\n"
func tryParseDataComment(s string) (map[string]any, bool) {
str += "import \"gogs.mikescher.com/BlackForestBytes/goext/langext\"" + "\n"
str += "\n"
str += "const ChecksumGenerator = \"" + cs + "\"" + "\n"
str += "\n"
str += "type Enum interface {" + "\n"
str += " Valid() bool" + "\n"
str += " ValuesAny() []any" + "\n"
str += " ValuesMeta() []EnumMetaValue" + "\n"
str += " VarName() string" + "\n"
str += "}" + "\n"
str += "" + "\n"
str += "type StringEnum interface {" + "\n"
str += " Enum" + "\n"
str += " String() string" + "\n"
str += "}" + "\n"
str += "" + "\n"
str += "type DescriptionEnum interface {" + "\n"
str += " Enum" + "\n"
str += " Description() string" + "\n"
str += "}" + "\n"
str += "\n"
str += "type EnumMetaValue struct {" + "\n"
str += " VarName string `json:\"varName\"`" + "\n"
str += " Value any `json:\"value\"`" + "\n"
str += " Description *string `json:\"description\"`" + "\n"
str += "}" + "\n"
str += "\n"
for _, enumdef := range enums {
hasDescr := langext.ArrAll(enumdef.Values, func(val EnumDefVal) bool { return val.Description != nil })
hasStr := enumdef.Type == "string"
str += "// ================================ " + enumdef.EnumTypeName + " ================================" + "\n"
str += "//" + "\n"
str += "// File: " + enumdef.File + "\n"
str += "// StringEnum: " + langext.Conditional(hasStr, "true", "false") + "\n"
str += "// DescrEnum: " + langext.Conditional(hasDescr, "true", "false") + "\n"
str += "//" + "\n"
str += "" + "\n"
str += "var __" + enumdef.EnumTypeName + "Values = []" + enumdef.EnumTypeName + "{" + "\n"
for _, v := range enumdef.Values {
str += " " + v.VarName + "," + "\n"
}
str += "}" + "\n"
str += "" + "\n"
if hasDescr {
str += "var __" + enumdef.EnumTypeName + "Descriptions = map[" + enumdef.EnumTypeName + "]string{" + "\n"
for _, v := range enumdef.Values {
str += " " + v.VarName + ": \"" + strings.TrimSpace(*v.Description) + "\"," + "\n"
}
str += "}" + "\n"
str += "" + "\n"
}
str += "var __" + enumdef.EnumTypeName + "Varnames = map[" + enumdef.EnumTypeName + "]string{" + "\n"
for _, v := range enumdef.Values {
str += " " + v.VarName + ": \"" + v.VarName + "\"," + "\n"
}
str += "}" + "\n"
str += "" + "\n"
str += "func (e " + enumdef.EnumTypeName + ") Valid() bool {" + "\n"
str += " return langext.InArray(e, __" + enumdef.EnumTypeName + "Values)" + "\n"
str += "}" + "\n"
str += "" + "\n"
str += "func (e " + enumdef.EnumTypeName + ") Values() []" + enumdef.EnumTypeName + " {" + "\n"
str += " return __" + enumdef.EnumTypeName + "Values" + "\n"
str += "}" + "\n"
str += "" + "\n"
str += "func (e " + enumdef.EnumTypeName + ") ValuesAny() []any {" + "\n"
str += " return langext.ArrCastToAny(__" + enumdef.EnumTypeName + "Values)" + "\n"
str += "}" + "\n"
str += "" + "\n"
str += "func (e " + enumdef.EnumTypeName + ") ValuesMeta() []EnumMetaValue {" + "\n"
str += " return []EnumMetaValue{" + "\n"
for _, v := range enumdef.Values {
if hasDescr {
str += " " + fmt.Sprintf("EnumMetaValue{VarName: \"%s\", Value: %s, Description: langext.Ptr(\"%s\")},", v.VarName, v.VarName, strings.TrimSpace(*v.Description)) + "\n"
} else {
str += " " + fmt.Sprintf("EnumMetaValue{VarName: \"%s\", Value: %s, Description: nil},", v.VarName, v.VarName) + "\n"
}
}
str += " }" + "\n"
str += "}" + "\n"
str += "" + "\n"
if hasStr {
str += "func (e " + enumdef.EnumTypeName + ") String() string {" + "\n"
str += " return string(e)" + "\n"
str += "}" + "\n"
str += "" + "\n"
}
if hasDescr {
str += "func (e " + enumdef.EnumTypeName + ") Description() string {" + "\n"
str += " if d, ok := __" + enumdef.EnumTypeName + "Descriptions[e]; ok {" + "\n"
str += " return d" + "\n"
str += " }" + "\n"
str += " return \"\"" + "\n"
str += "}" + "\n"
str += "" + "\n"
}
str += "func (e " + enumdef.EnumTypeName + ") VarName() string {" + "\n"
str += " if d, ok := __" + enumdef.EnumTypeName + "Varnames[e]; ok {" + "\n"
str += " return d" + "\n"
str += " }" + "\n"
str += " return \"\"" + "\n"
str += "}" + "\n"
str += "" + "\n"
str += "func Parse" + enumdef.EnumTypeName + "(vv string) (" + enumdef.EnumTypeName + ", bool) {" + "\n"
str += " for _, ev := range __" + enumdef.EnumTypeName + "Values {" + "\n"
str += " if string(ev) == vv {" + "\n"
str += " return ev, true" + "\n"
str += " }" + "\n"
str += " }" + "\n"
str += " return \"\", false" + "\n"
str += "}" + "\n"
str += "" + "\n"
str += "func " + enumdef.EnumTypeName + "Values() []" + enumdef.EnumTypeName + " {" + "\n"
str += " return __" + enumdef.EnumTypeName + "Values" + "\n"
str += "}" + "\n"
str += "" + "\n"
str += "func " + enumdef.EnumTypeName + "ValuesMeta() []EnumMetaValue {" + "\n"
str += " return []EnumMetaValue{" + "\n"
for _, v := range enumdef.Values {
if hasDescr {
str += " " + fmt.Sprintf("EnumMetaValue{VarName: \"%s\", Value: %s, Description: langext.Ptr(\"%s\")},", v.VarName, v.VarName, strings.TrimSpace(*v.Description)) + "\n"
} else {
str += " " + fmt.Sprintf("EnumMetaValue{VarName: \"%s\", Value: %s, Description: nil},", v.VarName, v.VarName) + "\n"
}
}
str += " }" + "\n"
str += "}" + "\n"
str += "" + "\n"
r := make(map[string]any)
err := json.Unmarshal([]byte(s), &r)
if err != nil {
return nil, false
}
return str
for _, v := range r {
rv := reflect.ValueOf(v)
if rv.Kind() == reflect.Pointer && rv.IsNil() {
continue
}
if rv.Kind() == reflect.Bool {
continue
}
if rv.Kind() == reflect.String {
continue
}
if rv.Kind() == reflect.Int64 {
continue
}
if rv.Kind() == reflect.Float64 {
continue
}
return nil, false
}
return r, true
}
func fmtEnumOutput(cs string, enums []EnumDef, pkgname string) string {
templ := template.New("enum-generate")
templ = templ.Funcs(template.FuncMap{
"boolToStr": func(b bool) string { return langext.Conditional(b, "true", "false") },
"deref": func(v *string) string { return *v },
"trimSpace": func(str string) string { return strings.TrimSpace(str) },
"hasStr": func(v EnumDef) bool { return v.Type == "string" },
"hasDescr": func(v EnumDef) bool {
return langext.ArrAll(v.Values, func(val EnumDefVal) bool { return val.Description != nil })
},
"hasData": func(v EnumDef) bool {
return len(v.Values) > 0 && langext.ArrAll(v.Values, func(val EnumDefVal) bool { return val.Data != nil })
},
"gostr": func(v any) string {
return fmt.Sprintf("%#+v", v)
},
"goobj": func(name string, v any) string {
return fmt.Sprintf("%#+v", v)
},
"godatakey": func(v string) string {
return strings.ToUpper(v[0:1]) + v[1:]
},
"godatavalue": func(v any) string {
return fmt.Sprintf("%#+v", v)
},
"godatatype": func(v any) string {
return fmt.Sprintf("%T", v)
},
"mapindex": func(v map[string]any, k string) any {
return v[k]
},
"generalDataKeys": func(v EnumDef) map[string]string {
r0 := make(map[string]int)
for _, eval := range v.Values {
for k := range *eval.Data {
if ctr, ok := r0[k]; ok {
r0[k] = ctr + 1
} else {
r0[k] = 1
}
}
}
r1 := langext.MapToArr(r0)
r2 := langext.ArrFilter(r1, func(p langext.MapEntry[string, int]) bool { return p.Value == len(v.Values) })
r3 := langext.ArrMap(r2, func(p langext.MapEntry[string, int]) string { return p.Key })
r4 := langext.ArrToKVMap(r3, func(p string) string { return p }, func(p string) string { return fmt.Sprintf("%T", (*v.Values[0].Data)[p]) })
return r4
},
})
templ = template.Must(templ.Parse(templateEnumGenerateText))
buffer := bytes.Buffer{}
err := templ.Execute(&buffer, langext.H{
"PkgName": pkgname,
"Checksum": cs,
"GoextVersion": goext.GoextVersion,
"Enums": enums,
})
if err != nil {
panic(err)
}
return buffer.String()
}
+177
View File
@@ -0,0 +1,177 @@
// Code generated by enum-generate.go DO NOT EDIT.
package {{.PkgName}}
import "git.blackforestbytes.com/BlackForestBytes/goext/langext"
import "git.blackforestbytes.com/BlackForestBytes/goext/enums"
const ChecksumEnumGenerator = "{{.Checksum}}" // GoExtVersion: {{.GoextVersion}}
{{ $pkgname := .PkgName }}
{{range .Enums}}
{{ $hasStr := ( . | hasStr ) }}
{{ $hasDescr := ( . | hasDescr ) }}
{{ $hasData := ( . | hasData ) }}
// ================================ {{.EnumTypeName}} ================================
//
// File: {{.FileRelative}}
// StringEnum: {{$hasStr | boolToStr}}
// DescrEnum: {{$hasDescr | boolToStr}}
// DataEnum: {{$hasData | boolToStr}}
//
{{ $typename := .EnumTypeName }}
{{ $enumdef := . }}
var __{{.EnumTypeName}}Values = []{{.EnumTypeName}}{ {{range .Values}}
{{.VarName}}, {{end}}
}
{{if $hasDescr}}
var __{{.EnumTypeName}}Descriptions = map[{{.EnumTypeName}}]string{ {{range .Values}}
{{.VarName}}: {{.Description | deref | trimSpace | gostr}}, {{end}}
}
{{end}}
{{if $hasData}}
type {{ .EnumTypeName }}Data struct { {{ range $datakey, $datatype := ($enumdef | generalDataKeys) }}
{{ $datakey | godatakey }} {{ $datatype }} `json:"{{ $datakey }}"` {{ end }}
}
var __{{.EnumTypeName}}Data = map[{{.EnumTypeName}}]{{.EnumTypeName}}Data{ {{range .Values}} {{ $enumvalue := . }}
{{.VarName}}: {{ $typename }}Data{ {{ range $datakey, $datatype := $enumdef | generalDataKeys }}
{{ $datakey | godatakey }}: {{ (mapindex $enumvalue.Data $datakey) | godatavalue }}, {{ end }}
}, {{end}}
}
{{end}}
var __{{.EnumTypeName}}Varnames = map[{{.EnumTypeName}}]string{ {{range .Values}}
{{.VarName}}: "{{.VarName}}", {{end}}
}
func (e {{.EnumTypeName}}) Valid() bool {
return langext.InArray(e, __{{.EnumTypeName}}Values)
}
func (e {{.EnumTypeName}}) Values() []{{.EnumTypeName}} {
return __{{.EnumTypeName}}Values
}
func (e {{.EnumTypeName}}) ValuesAny() []any {
return langext.ArrCastToAny(__{{.EnumTypeName}}Values)
}
func (e {{.EnumTypeName}}) ValuesMeta() []enums.EnumMetaValue {
return {{.EnumTypeName}}ValuesMeta()
}
{{if $hasStr}}
func (e {{.EnumTypeName}}) String() string {
return string(e)
}
{{end}}
{{if $hasDescr}}
func (e {{.EnumTypeName}}) Description() string {
if d, ok := __{{.EnumTypeName}}Descriptions[e]; ok {
return d
}
return ""
}
{{end}}
{{if $hasData}}
func (e {{.EnumTypeName}}) Data() {{.EnumTypeName}}Data {
if d, ok := __{{.EnumTypeName}}Data[e]; ok {
return d
}
return {{.EnumTypeName}}Data{}
}
{{end}}
func (e {{.EnumTypeName}}) VarName() string {
if d, ok := __{{.EnumTypeName}}Varnames[e]; ok {
return d
}
return ""
}
func (e {{.EnumTypeName}}) TypeName() string {
return "{{$typename}}"
}
func (e {{.EnumTypeName}}) PackageName() string {
return "{{$pkgname }}"
}
func (e {{.EnumTypeName}}) Meta() enums.EnumMetaValue {
{{if $hasDescr}} return enums.EnumMetaValue{VarName: e.VarName(), Value: e, Description: new(e.Description())} {{else}} return enums.EnumMetaValue{VarName: e.VarName(), Value: e, Description: nil} {{end}}
}
{{if $hasDescr}}
func (e {{.EnumTypeName}}) DescriptionMeta() enums.EnumDescriptionMetaValue {
return enums.EnumDescriptionMetaValue{VarName: e.VarName(), Value: e, Description: e.Description()}
}
{{end}}
{{if $hasData}}
func (e {{.EnumTypeName}}) DataMeta() enums.EnumDataMetaValue {
return enums.EnumDataMetaValue{
VarName: e.VarName(),
Value: e,
{{if $hasDescr}} Description: new(e.Description()), {{else}} Description: nil, {{end}}
Data: map[string]any{
{{ range $datakey, $datatype := $enumdef | generalDataKeys }} "{{ $datakey }}": e.Data().{{ $datakey | godatakey }},
{{ end }}
},
}
}
{{end}}
func Parse{{.EnumTypeName}}(vv string) ({{.EnumTypeName}}, bool) {
for _, ev := range __{{.EnumTypeName}}Values {
if string(ev) == vv {
return ev, true
}
}
return "", false
}
func {{.EnumTypeName}}Values() []{{.EnumTypeName}} {
return __{{.EnumTypeName}}Values
}
func {{.EnumTypeName}}ValuesMeta() []enums.EnumMetaValue {
return []enums.EnumMetaValue{ {{range .Values}}
{{.VarName}}.Meta(), {{end}}
}
}
{{if $hasData}}
func {{.EnumTypeName}}ValuesDataMeta() []enums.EnumDataMetaValue {
return []enums.EnumDataMetaValue{ {{range .Values}}
{{.VarName}}.DataMeta(), {{end}}
}
}
{{end}}
{{if $hasDescr}}
func {{.EnumTypeName}}ValuesDescriptionMeta() []enums.EnumDescriptionMetaValue {
return []enums.EnumDescriptionMetaValue{ {{range .Values}}
{{.VarName}}.DescriptionMeta(), {{end}}
}
}
{{end}}
{{end}}
// ================================ ================= ================================
func AllPackageEnums() []enums.Enum {
return []enums.Enum{ {{range .Enums}}
{{ if gt (len .Values) 0 }} {{ $v := index .Values 0 }} {{ $v.VarName}}, {{end}} // {{ .EnumTypeName }} {{end}}
}
}
+82 -6
View File
@@ -1,15 +1,91 @@
package bfcodegen
import (
_ "embed"
"fmt"
"git.blackforestbytes.com/BlackForestBytes/goext/cmdext"
"git.blackforestbytes.com/BlackForestBytes/goext/langext"
"git.blackforestbytes.com/BlackForestBytes/goext/tst"
"os"
"path/filepath"
"testing"
"time"
)
func TestApplyEnvOverridesSimple(t *testing.T) {
//go:embed _test_example_1.tgz
var EnumExampleModels1 []byte
err := GenerateEnumSpecs("/home/mike/Code/reiff/badennet/bnet-backend/models", "/home/mike/Code/reiff/badennet/bnet-backend/models/enums_gen.go")
if err != nil {
t.Error(err)
t.Fail()
}
//go:embed _test_example_2.tgz
var EnumExampleModels2 []byte
func TestGenerateEnumSpecs(t *testing.T) {
tmpFile := filepath.Join(t.TempDir(), langext.MustHexUUID()+".tgz")
tmpDir := filepath.Join(t.TempDir(), langext.MustHexUUID())
err := os.WriteFile(tmpFile, EnumExampleModels1, 0o777)
tst.AssertNoErr(t, err)
t.Cleanup(func() { _ = os.Remove(tmpFile) })
err = os.Mkdir(tmpDir, 0o777)
tst.AssertNoErr(t, err)
t.Cleanup(func() { _ = os.RemoveAll(tmpFile) })
_, err = cmdext.Runner("tar").Arg("-xvzf").Arg(tmpFile).Arg("-C").Arg(tmpDir).FailOnExitCode().FailOnTimeout().Timeout(time.Minute).Run()
tst.AssertNoErr(t, err)
s1, cs1, _, err := _generateEnumSpecs(tmpDir, "", "N/A", true, true)
tst.AssertNoErr(t, err)
s2, cs2, _, err := _generateEnumSpecs(tmpDir, "", "N/A", true, true)
tst.AssertNoErr(t, err)
tst.AssertEqual(t, cs1, cs2)
tst.AssertEqual(t, s1, s2)
fmt.Println()
fmt.Println()
fmt.Println()
fmt.Println("=====================================================================================================")
fmt.Println(s1)
fmt.Println("=====================================================================================================")
fmt.Println()
fmt.Println()
fmt.Println()
}
func TestGenerateEnumSpecsData(t *testing.T) {
tmpFile := filepath.Join(t.TempDir(), langext.MustHexUUID()+".tgz")
tmpDir := filepath.Join(t.TempDir(), langext.MustHexUUID())
err := os.WriteFile(tmpFile, EnumExampleModels2, 0o777)
tst.AssertNoErr(t, err)
t.Cleanup(func() { _ = os.Remove(tmpFile) })
err = os.Mkdir(tmpDir, 0o777)
tst.AssertNoErr(t, err)
t.Cleanup(func() { _ = os.RemoveAll(tmpFile) })
_, err = cmdext.Runner("tar").Arg("-xvzf").Arg(tmpFile).Arg("-C").Arg(tmpDir).FailOnExitCode().FailOnTimeout().Timeout(time.Minute).Run()
tst.AssertNoErr(t, err)
s1, _, _, err := _generateEnumSpecs(tmpDir, "", "", true, true)
tst.AssertNoErr(t, err)
fmt.Println()
fmt.Println()
fmt.Println()
fmt.Println("=====================================================================================================")
fmt.Println(s1)
fmt.Println("=====================================================================================================")
fmt.Println()
fmt.Println()
fmt.Println()
}
+199
View File
@@ -0,0 +1,199 @@
package bfcodegen
import (
"bytes"
_ "embed"
"errors"
"fmt"
"git.blackforestbytes.com/BlackForestBytes/goext"
"git.blackforestbytes.com/BlackForestBytes/goext/cryptext"
"git.blackforestbytes.com/BlackForestBytes/goext/langext"
"git.blackforestbytes.com/BlackForestBytes/goext/rext"
"go/format"
"io"
"os"
"path"
"path/filepath"
"regexp"
"strings"
"text/template"
)
type IDDef struct {
File string
FileRelative string
Name string
}
type IDGenOptions struct {
DebugOutput *bool
}
var rexIDPackage = rext.W(regexp.MustCompile(`^package\s+(?P<name>[A-Za-z0-9_]+)\s*$`))
var rexIDDef = rext.W(regexp.MustCompile(`^\s*type\s+(?P<name>[A-Za-z0-9_]+)\s+string\s*//\s*(@id:type).*$`))
var rexIDChecksumConst = rext.W(regexp.MustCompile(`const ChecksumIDGenerator = "(?P<cs>[A-Za-z0-9_]*)"`))
//go:embed id-generate.template
var templateIDGenerateText string
func GenerateIDSpecs(sourceDir string, destFile string, opt IDGenOptions) error {
debugOutput := langext.Coalesce(opt.DebugOutput, false)
files, err := os.ReadDir(sourceDir)
if err != nil {
return err
}
oldChecksum := "N/A"
if _, err := os.Stat(destFile); !os.IsNotExist(err) {
content, err := os.ReadFile(destFile)
if err != nil {
return err
}
if m, ok := rexIDChecksumConst.MatchFirst(string(content)); ok {
oldChecksum = m.GroupByName("cs").Value()
}
}
files = langext.ArrFilter(files, func(v os.DirEntry) bool { return v.Name() != path.Base(destFile) })
files = langext.ArrFilter(files, func(v os.DirEntry) bool { return strings.HasSuffix(v.Name(), ".go") })
files = langext.ArrFilter(files, func(v os.DirEntry) bool { return !strings.HasSuffix(v.Name(), "_gen.go") })
langext.SortBy(files, func(v os.DirEntry) string { return v.Name() })
var newChecksumStr strings.Builder
newChecksumStr.WriteString(goext.GoextVersion)
for _, f := range files {
content, err := os.ReadFile(path.Join(sourceDir, f.Name()))
if err != nil {
return err
}
newChecksumStr.WriteString("\n" + f.Name() + "\t" + cryptext.BytesSha256(content))
}
newChecksum := cryptext.BytesSha256([]byte(newChecksumStr.String()))
if newChecksum != oldChecksum {
fmt.Printf("[IDGenerate] Checksum has changed ( %s -> %s ), will generate new file\n\n", oldChecksum, newChecksum)
} else {
fmt.Printf("[IDGenerate] Checksum unchanged ( %s ), nothing to do\n", oldChecksum)
return nil
}
allIDs := make([]IDDef, 0)
pkgname := ""
for _, f := range files {
if debugOutput {
fmt.Printf("========= %s =========\n\n", f.Name())
}
fileIDs, pn, err := processIDFile(sourceDir, path.Join(sourceDir, f.Name()), debugOutput)
if err != nil {
return err
}
if debugOutput {
fmt.Printf("\n")
}
allIDs = append(allIDs, fileIDs...)
if pn != "" {
pkgname = pn
}
}
if pkgname == "" {
return errors.New("no package name found in any file")
}
fdata, err := format.Source([]byte(fmtIDOutput(newChecksum, allIDs, pkgname)))
if err != nil {
return err
}
err = os.WriteFile(destFile, fdata, 0o755)
if err != nil {
return err
}
return nil
}
func processIDFile(basedir string, fn string, debugOutput bool) ([]IDDef, string, error) {
file, err := os.Open(fn)
if err != nil {
return nil, "", err
}
defer func() { _ = file.Close() }()
bin, err := io.ReadAll(file)
if err != nil {
return nil, "", err
}
lines := strings.Split(string(bin), "\n")
ids := make([]IDDef, 0)
pkgname := ""
for i, line := range lines {
if i == 0 && strings.HasPrefix(line, "// Code generated by") {
break
}
if match, ok := rexIDPackage.MatchFirst(line); i == 0 && ok {
pkgname = match.GroupByName("name").Value()
continue
}
if match, ok := rexIDDef.MatchFirst(line); ok {
rfp, err := filepath.Rel(basedir, fn)
if err != nil {
return nil, "", err
}
def := IDDef{
File: fn,
FileRelative: rfp,
Name: match.GroupByName("name").Value(),
}
if debugOutput {
fmt.Printf("Found ID definition { '%s' }\n", def.Name)
}
ids = append(ids, def)
}
}
return ids, pkgname, nil
}
func fmtIDOutput(cs string, ids []IDDef, pkgname string) string {
templ := template.Must(template.New("id-generate").Parse(templateIDGenerateText))
buffer := bytes.Buffer{}
anyDef := langext.ArrFirstOrNil(ids, func(def IDDef) bool { return def.Name == "AnyID" || def.Name == "AnyId" })
err := templ.Execute(&buffer, langext.H{
"PkgName": pkgname,
"Checksum": cs,
"GoextVersion": goext.GoextVersion,
"IDs": ids,
"AnyDef": anyDef,
})
if err != nil {
panic(err)
}
return buffer.String()
}
+55
View File
@@ -0,0 +1,55 @@
// Code generated by id-generate.go DO NOT EDIT.
package {{.PkgName}}
import "go.mongodb.org/mongo-driver/v2/bson"
import "git.blackforestbytes.com/BlackForestBytes/goext/exerr"
const ChecksumIDGenerator = "{{.Checksum}}" // GoExtVersion: {{.GoextVersion}}
{{range .IDs}}
// ================================ {{.Name}} ({{.FileRelative}}) ================================
func (i {{.Name}}) MarshalBSONValue() (byte, []byte, error) {
if objId, err := bson.ObjectIDFromHex(string(i)); err == nil {
tp, data, err := bson.MarshalValue(objId)
return byte(tp), data, err
} else {
return 0, nil, exerr.New(exerr.TypeMarshalEntityID, "Failed to marshal {{.Name}}("+i.String()+") to ObjectId").Str("value", string(i)).Type("type", i).Build()
}
}
func (i {{.Name}}) String() string {
return string(i)
}
func (i {{.Name}}) ObjID() (bson.ObjectID, error) {
return bson.ObjectIDFromHex(string(i))
}
func (i {{.Name}}) Valid() bool {
_, err := bson.ObjectIDFromHex(string(i))
return err == nil
}
{{if ne $.AnyDef nil}}
func (i {{.Name}}) AsAny() {{$.AnyDef.Name}} {
return {{$.AnyDef.Name}}(i)
}
func (i {{.Name}}) AsAnyPtr() *{{$.AnyDef.Name}} {
v := {{$.AnyDef.Name}}(i)
return &v
}
{{end}}
func (i {{.Name}}) IsZero() bool {
return i == ""
}
func New{{.Name}}() {{.Name}} {
return {{.Name}}(bson.NewObjectID().Hex())
}
{{end}}
+52
View File
@@ -0,0 +1,52 @@
package bfcodegen
import (
_ "embed"
"fmt"
"git.blackforestbytes.com/BlackForestBytes/goext/cmdext"
"git.blackforestbytes.com/BlackForestBytes/goext/langext"
"git.blackforestbytes.com/BlackForestBytes/goext/tst"
"os"
"path/filepath"
"testing"
"time"
)
//go:embed _test_example_1.tgz
var IDExampleModels1 []byte
func TestGenerateIDSpecs(t *testing.T) {
tmpFile := filepath.Join(t.TempDir(), langext.MustHexUUID()+".tgz")
tmpDir := filepath.Join(t.TempDir(), langext.MustHexUUID())
err := os.WriteFile(tmpFile, IDExampleModels1, 0o777)
tst.AssertNoErr(t, err)
t.Cleanup(func() { _ = os.Remove(tmpFile) })
err = os.Mkdir(tmpDir, 0o777)
tst.AssertNoErr(t, err)
t.Cleanup(func() { _ = os.RemoveAll(tmpFile) })
_, err = cmdext.Runner("tar").Arg("-xvzf").Arg(tmpFile).Arg("-C").Arg(tmpDir).FailOnExitCode().FailOnTimeout().Timeout(time.Minute).Run()
tst.AssertNoErr(t, err)
err = GenerateIDSpecs(tmpDir, tmpDir+"/id_gen.go", IDGenOptions{DebugOutput: langext.PTrue})
tst.AssertNoErr(t, err)
err = GenerateIDSpecs(tmpDir, tmpDir+"/id_gen.go", IDGenOptions{DebugOutput: langext.PTrue})
tst.AssertNoErr(t, err)
fmt.Println()
fmt.Println()
fmt.Println()
fmt.Println("=====================================================================================================")
fmt.Println(string(tst.Must(os.ReadFile(tmpDir + "/id_gen.go"))(t)))
fmt.Println("=====================================================================================================")
fmt.Println()
fmt.Println()
fmt.Println()
}
+160
View File
@@ -0,0 +1,160 @@
package bfcodegen
import (
"git.blackforestbytes.com/BlackForestBytes/goext/langext"
"git.blackforestbytes.com/BlackForestBytes/goext/tst"
"os"
"path/filepath"
"strings"
"testing"
)
func TestProcessCSIDFileSimple(t *testing.T) {
dir := t.TempDir()
src := `package mymodels
type UserID string // @csid:type [USR]
type OrderID string // @csid:type [ORD]
`
fp := writeTestFile(t, dir, "models.go", src)
ids, pkg, err := processCSIDFile(dir, fp, false)
tst.AssertNoErr(t, err)
tst.AssertEqual(t, pkg, "mymodels")
tst.AssertEqual(t, len(ids), 2)
tst.AssertEqual(t, ids[0].Name, "UserID")
tst.AssertEqual(t, ids[0].Prefix, "USR")
tst.AssertEqual(t, ids[1].Name, "OrderID")
tst.AssertEqual(t, ids[1].Prefix, "ORD")
tst.AssertEqual(t, ids[0].FileRelative, "models.go")
}
func TestProcessCSIDFilePrefixMustBeUppercase(t *testing.T) {
dir := t.TempDir()
// lowercase prefix should not match the regex (only [A-Z0-9]{3})
src := `package x
type FooID string // @csid:type [usr]
`
fp := writeTestFile(t, dir, "x.go", src)
ids, pkg, err := processCSIDFile(dir, fp, false)
tst.AssertNoErr(t, err)
tst.AssertEqual(t, pkg, "x")
tst.AssertEqual(t, len(ids), 0)
}
func TestProcessCSIDFileGeneratedHeaderSkipped(t *testing.T) {
dir := t.TempDir()
src := `// Code generated by csid-generate.go DO NOT EDIT.
package x
type SkipMeID string // @csid:type [SKP]
`
fp := writeTestFile(t, dir, "skip.go", src)
ids, pkg, err := processCSIDFile(dir, fp, false)
tst.AssertNoErr(t, err)
tst.AssertEqual(t, pkg, "")
tst.AssertEqual(t, len(ids), 0)
}
func TestGenerateCharsetIDSpecsEndToEnd(t *testing.T) {
dir := t.TempDir()
src1 := `package models
type EntityID string // @csid:type [ENT]
type UserID string // @csid:type [USR]
`
writeTestFile(t, dir, "a_models.go", src1)
src2 := `package models
type OrderID string // @csid:type [ORD]
`
writeTestFile(t, dir, "b_models.go", src2)
dest := filepath.Join(dir, "csid_gen.go")
err := GenerateCharsetIDSpecs(dir, dest, CSIDGenOptions{DebugOutput: langext.PFalse})
tst.AssertNoErr(t, err)
out, err := os.ReadFile(dest)
tst.AssertNoErr(t, err)
outStr := string(out)
tst.AssertTrue(t, strings.Contains(outStr, "package models"))
tst.AssertTrue(t, strings.Contains(outStr, "ChecksumCharsetIDGenerator"))
tst.AssertTrue(t, strings.Contains(outStr, "func NewUserID()"))
tst.AssertTrue(t, strings.Contains(outStr, "func NewOrderID()"))
tst.AssertTrue(t, strings.Contains(outStr, "func NewEntityID()"))
tst.AssertTrue(t, strings.Contains(outStr, `prefixUserID`) && strings.Contains(outStr, `"USR"`))
tst.AssertTrue(t, strings.Contains(outStr, `prefixOrderID`) && strings.Contains(outStr, `"ORD"`))
tst.AssertTrue(t, strings.Contains(outStr, `prefixEntityID`) && strings.Contains(outStr, `"ENT"`))
}
func TestGenerateCharsetIDSpecsIdempotentWhenUnchanged(t *testing.T) {
dir := t.TempDir()
src := `package models
type SomeID string // @csid:type [SOM]
`
writeTestFile(t, dir, "models.go", src)
dest := filepath.Join(dir, "csid_gen.go")
err := GenerateCharsetIDSpecs(dir, dest, CSIDGenOptions{})
tst.AssertNoErr(t, err)
content1, err := os.ReadFile(dest)
tst.AssertNoErr(t, err)
err = GenerateCharsetIDSpecs(dir, dest, CSIDGenOptions{})
tst.AssertNoErr(t, err)
content2, err := os.ReadFile(dest)
tst.AssertNoErr(t, err)
tst.AssertEqual(t, string(content1), string(content2))
}
func TestGenerateCharsetIDSpecsErrorsWithoutPackage(t *testing.T) {
dir := t.TempDir()
src := `// Code generated by csid-generate.go DO NOT EDIT.
package x
type SkippedID string // @csid:type [SKP]
`
writeTestFile(t, dir, "z.go", src)
dest := filepath.Join(dir, "csid_gen.go")
err := GenerateCharsetIDSpecs(dir, dest, CSIDGenOptions{})
tst.AssertTrue(t, err != nil)
}
func TestGenerateCharsetIDSpecsMissingDir(t *testing.T) {
dir := filepath.Join(t.TempDir(), "definitely-missing")
err := GenerateCharsetIDSpecs(dir, filepath.Join(dir, "csid_gen.go"), CSIDGenOptions{})
tst.AssertTrue(t, err != nil)
}
func TestFmtCSIDOutputContainsAllNames(t *testing.T) {
ids := []CSIDDef{
{File: "a.go", FileRelative: "a.go", Name: "AlphaID", Prefix: "ALP"},
{File: "b.go", FileRelative: "b.go", Name: "BetaID", Prefix: "BET"},
}
out := fmtCSIDOutput("CHK_XYZ", ids, "models")
tst.AssertTrue(t, strings.Contains(out, "package models"))
tst.AssertTrue(t, strings.Contains(out, "CHK_XYZ"))
tst.AssertTrue(t, strings.Contains(out, "AlphaID"))
tst.AssertTrue(t, strings.Contains(out, "BetaID"))
tst.AssertTrue(t, strings.Contains(out, `prefixAlphaID`) && strings.Contains(out, `"ALP"`))
tst.AssertTrue(t, strings.Contains(out, `prefixBetaID`) && strings.Contains(out, `"BET"`))
}
+369
View File
@@ -0,0 +1,369 @@
package bfcodegen
import (
"git.blackforestbytes.com/BlackForestBytes/goext/langext"
"git.blackforestbytes.com/BlackForestBytes/goext/tst"
"os"
"path/filepath"
"strings"
"testing"
)
func TestProcessEnumFileBasicStringEnum(t *testing.T) {
dir := t.TempDir()
src := `package mymodels
type Color string // @enum:type
const (
ColorRed Color = "red"
ColorBlue Color = "blue"
ColorGreen Color = "green"
)
`
fp := writeTestFile(t, dir, "color.go", src)
enums, pkg, err := processEnumFile(dir, fp, false)
tst.AssertNoErr(t, err)
tst.AssertEqual(t, pkg, "mymodels")
tst.AssertEqual(t, len(enums), 1)
tst.AssertEqual(t, enums[0].EnumTypeName, "Color")
tst.AssertEqual(t, enums[0].Type, "string")
tst.AssertEqual(t, len(enums[0].Values), 3)
tst.AssertEqual(t, enums[0].Values[0].VarName, "ColorRed")
tst.AssertEqual(t, enums[0].Values[0].Value, `"red"`)
tst.AssertEqual(t, enums[0].Values[1].VarName, "ColorBlue")
tst.AssertEqual(t, enums[0].Values[2].VarName, "ColorGreen")
}
func TestProcessEnumFileIntEnum(t *testing.T) {
dir := t.TempDir()
src := `package m
type Priority int // @enum:type
const (
PriorityLow Priority = 0
PriorityMedium Priority = 1
PriorityHigh Priority = 2
)
`
fp := writeTestFile(t, dir, "prio.go", src)
enums, pkg, err := processEnumFile(dir, fp, false)
tst.AssertNoErr(t, err)
tst.AssertEqual(t, pkg, "m")
tst.AssertEqual(t, len(enums), 1)
tst.AssertEqual(t, enums[0].EnumTypeName, "Priority")
tst.AssertEqual(t, enums[0].Type, "int")
tst.AssertEqual(t, len(enums[0].Values), 3)
tst.AssertEqual(t, enums[0].Values[0].Value, "0")
tst.AssertEqual(t, enums[0].Values[2].Value, "2")
}
func TestProcessEnumFileWithDescriptions(t *testing.T) {
dir := t.TempDir()
src := `package m
type Status string // @enum:type
const (
StatusActive Status = "active" // The active status
StatusInactive Status = "inactive" // The inactive status
)
`
fp := writeTestFile(t, dir, "s.go", src)
enums, _, err := processEnumFile(dir, fp, false)
tst.AssertNoErr(t, err)
tst.AssertEqual(t, len(enums), 1)
tst.AssertEqual(t, len(enums[0].Values), 2)
v0 := enums[0].Values[0]
tst.AssertTrue(t, v0.Description != nil)
tst.AssertEqual(t, *v0.Description, "The active status")
tst.AssertTrue(t, v0.Data == nil)
}
func TestProcessEnumFileWithDataComment(t *testing.T) {
dir := t.TempDir()
src := `package m
type Severity string // @enum:type
const (
SeverityLow Severity = "low" // {"description": "Low severity", "weight": 1}
SeverityHigh Severity = "high" // {"description": "High severity", "weight": 9}
)
`
fp := writeTestFile(t, dir, "sev.go", src)
enums, _, err := processEnumFile(dir, fp, false)
tst.AssertNoErr(t, err)
tst.AssertEqual(t, len(enums), 1)
tst.AssertEqual(t, len(enums[0].Values), 2)
v0 := enums[0].Values[0]
tst.AssertTrue(t, v0.Data != nil)
tst.AssertTrue(t, v0.Description != nil)
tst.AssertEqual(t, *v0.Description, "Low severity")
}
func TestProcessEnumFileNonMatchingValuesNotAttached(t *testing.T) {
dir := t.TempDir()
src := `package m
type Color string // @enum:type
const (
ColorRed Color = "red"
)
const (
OtherX OtherType = "x"
)
`
fp := writeTestFile(t, dir, "c.go", src)
enums, _, err := processEnumFile(dir, fp, false)
tst.AssertNoErr(t, err)
tst.AssertEqual(t, len(enums), 1)
tst.AssertEqual(t, len(enums[0].Values), 1)
tst.AssertEqual(t, enums[0].Values[0].VarName, "ColorRed")
}
func TestProcessEnumFileGeneratedHeaderSkipped(t *testing.T) {
dir := t.TempDir()
src := `// Code generated by enum-generate.go DO NOT EDIT.
package x
type Foo string // @enum:type
`
fp := writeTestFile(t, dir, "skip.go", src)
enums, pkg, err := processEnumFile(dir, fp, false)
tst.AssertNoErr(t, err)
tst.AssertEqual(t, pkg, "")
tst.AssertEqual(t, len(enums), 0)
}
func TestTryParseDataCommentValid(t *testing.T) {
m, ok := tryParseDataComment(`{"description": "hello", "weight": 5}`)
tst.AssertTrue(t, ok)
descr, _ := m["description"].(string)
tst.AssertEqual(t, descr, "hello")
weight, _ := m["weight"].(float64)
tst.AssertEqual(t, weight, float64(5))
}
func TestTryParseDataCommentBool(t *testing.T) {
m, ok := tryParseDataComment(`{"a": true, "b": false}`)
tst.AssertTrue(t, ok)
a, _ := m["a"].(bool)
tst.AssertTrue(t, a)
b, _ := m["b"].(bool)
tst.AssertFalse(t, b)
}
func TestTryParseDataCommentRejectsNull(t *testing.T) {
// null becomes a nil interface — its reflect.Kind is Invalid, not Pointer,
// so it does not match any of the allowed kinds and is rejected.
_, ok := tryParseDataComment(`{"x": null}`)
tst.AssertFalse(t, ok)
}
func TestTryParseDataCommentInvalidJSON(t *testing.T) {
_, ok := tryParseDataComment(`{not valid json}`)
tst.AssertFalse(t, ok)
}
func TestTryParseDataCommentRejectsArrays(t *testing.T) {
// arrays as values are not in the supported kinds list
_, ok := tryParseDataComment(`{"x": [1, 2, 3]}`)
tst.AssertFalse(t, ok)
}
func TestTryParseDataCommentRejectsObjects(t *testing.T) {
_, ok := tryParseDataComment(`{"x": {"nested": 1}}`)
tst.AssertFalse(t, ok)
}
func TestGenerateEnumSpecsEndToEnd(t *testing.T) {
dir := t.TempDir()
src := `package models
type Color string // @enum:type
const (
ColorRed Color = "red"
ColorGreen Color = "green"
ColorBlue Color = "blue"
)
`
writeTestFile(t, dir, "color.go", src)
dest := filepath.Join(dir, "enum_gen.go")
err := GenerateEnumSpecs(dir, dest, EnumGenOptions{
DebugOutput: langext.PFalse,
GoFormat: langext.PTrue,
})
tst.AssertNoErr(t, err)
out, err := os.ReadFile(dest)
tst.AssertNoErr(t, err)
outStr := string(out)
tst.AssertTrue(t, strings.Contains(outStr, "package models"))
tst.AssertTrue(t, strings.Contains(outStr, "ChecksumEnumGenerator"))
tst.AssertTrue(t, strings.Contains(outStr, "ParseColor"))
tst.AssertTrue(t, strings.Contains(outStr, "ColorValues"))
tst.AssertTrue(t, strings.Contains(outStr, "ColorRed"))
tst.AssertTrue(t, strings.Contains(outStr, "ColorBlue"))
tst.AssertTrue(t, strings.Contains(outStr, "ColorGreen"))
}
func TestGenerateEnumSpecsDeterministic(t *testing.T) {
dir := t.TempDir()
src := `package models
type Status string // @enum:type
const (
StatusActive Status = "active" // The active one
StatusOff Status = "off" // The off one
)
`
writeTestFile(t, dir, "s.go", src)
s1, cs1, changed1, err := _generateEnumSpecs(dir, "", "N/A", true, false)
tst.AssertNoErr(t, err)
tst.AssertTrue(t, changed1)
s2, cs2, changed2, err := _generateEnumSpecs(dir, "", "N/A", true, false)
tst.AssertNoErr(t, err)
tst.AssertTrue(t, changed2)
tst.AssertEqual(t, cs1, cs2)
tst.AssertEqual(t, s1, s2)
}
func TestGenerateEnumSpecsNoChangeWhenChecksumMatches(t *testing.T) {
dir := t.TempDir()
src := `package models
type Status string // @enum:type
const (
StatusActive Status = "active"
)
`
writeTestFile(t, dir, "s.go", src)
_, cs, changed, err := _generateEnumSpecs(dir, "", "N/A", true, false)
tst.AssertNoErr(t, err)
tst.AssertTrue(t, changed)
s2, cs2, changed2, err := _generateEnumSpecs(dir, "", cs, true, false)
tst.AssertNoErr(t, err)
tst.AssertFalse(t, changed2)
tst.AssertEqual(t, cs2, cs)
tst.AssertEqual(t, s2, "")
}
func TestGenerateEnumSpecsWithoutGoFormat(t *testing.T) {
dir := t.TempDir()
src := `package models
type Color string // @enum:type
const (
ColorRed Color = "red"
)
`
writeTestFile(t, dir, "c.go", src)
out, _, _, err := _generateEnumSpecs(dir, "", "N/A", false, false)
tst.AssertNoErr(t, err)
tst.AssertTrue(t, strings.Contains(out, "ColorRed"))
tst.AssertTrue(t, strings.Contains(out, "package models"))
}
func TestGenerateEnumSpecsErrorsWithoutPackage(t *testing.T) {
dir := t.TempDir()
src := `// Code generated by enum-generate.go DO NOT EDIT.
package x
type Foo string // @enum:type
`
writeTestFile(t, dir, "z.go", src)
_, _, _, err := _generateEnumSpecs(dir, "", "N/A", false, false)
tst.AssertTrue(t, err != nil)
}
func TestGenerateEnumSpecsMissingDir(t *testing.T) {
dir := filepath.Join(t.TempDir(), "definitely-missing")
_, _, _, err := _generateEnumSpecs(dir, "", "N/A", false, false)
tst.AssertTrue(t, err != nil)
}
func TestFmtEnumOutputContainsTypes(t *testing.T) {
descr := "the red one"
enums := []EnumDef{
{
File: "color.go",
FileRelative: "color.go",
EnumTypeName: "Color",
Type: "string",
Values: []EnumDefVal{
{VarName: "ColorRed", Value: `"red"`, Description: &descr},
{VarName: "ColorBlue", Value: `"blue"`, Description: &descr},
},
},
}
out := fmtEnumOutput("CHK1", enums, "models")
tst.AssertTrue(t, strings.Contains(out, "package models"))
tst.AssertTrue(t, strings.Contains(out, "CHK1"))
tst.AssertTrue(t, strings.Contains(out, "ColorRed"))
tst.AssertTrue(t, strings.Contains(out, "ColorBlue"))
tst.AssertTrue(t, strings.Contains(out, "ParseColor"))
}
func TestGenerateEnumSpecsSkipsGenFile(t *testing.T) {
dir := t.TempDir()
src := `package models
type Color string // @enum:type
const (
ColorRed Color = "red"
)
`
writeTestFile(t, dir, "c.go", src)
// generated file in same dir - should be filtered out
gensrc := `package models
type ShouldBeIgnored string // @enum:type
`
writeTestFile(t, dir, "ignored_gen.go", gensrc)
out, _, _, err := _generateEnumSpecs(dir, filepath.Join(dir, "enum_gen.go"), "N/A", false, false)
tst.AssertNoErr(t, err)
tst.AssertTrue(t, strings.Contains(out, "ColorRed"))
tst.AssertFalse(t, strings.Contains(out, "ShouldBeIgnored"))
}
+209
View File
@@ -0,0 +1,209 @@
package bfcodegen
import (
"git.blackforestbytes.com/BlackForestBytes/goext/langext"
"git.blackforestbytes.com/BlackForestBytes/goext/tst"
"os"
"path/filepath"
"strings"
"testing"
)
func writeTestFile(t *testing.T, dir string, name string, content string) string {
t.Helper()
p := filepath.Join(dir, name)
err := os.WriteFile(p, []byte(content), 0o644)
tst.AssertNoErr(t, err)
return p
}
func TestProcessIDFileSimple(t *testing.T) {
dir := t.TempDir()
src := `package mymodels
type UserID string // @id:type
type OrderID string // @id:type
`
fp := writeTestFile(t, dir, "models.go", src)
ids, pkg, err := processIDFile(dir, fp, false)
tst.AssertNoErr(t, err)
tst.AssertEqual(t, pkg, "mymodels")
tst.AssertEqual(t, len(ids), 2)
tst.AssertEqual(t, ids[0].Name, "UserID")
tst.AssertEqual(t, ids[1].Name, "OrderID")
tst.AssertEqual(t, ids[0].FileRelative, "models.go")
}
func TestProcessIDFileNoMatches(t *testing.T) {
dir := t.TempDir()
src := `package x
type Foo string
type Bar int
type Baz string // not the right marker
`
fp := writeTestFile(t, dir, "x.go", src)
ids, pkg, err := processIDFile(dir, fp, false)
tst.AssertNoErr(t, err)
tst.AssertEqual(t, pkg, "x")
tst.AssertEqual(t, len(ids), 0)
}
func TestProcessIDFileGeneratedHeaderSkipped(t *testing.T) {
dir := t.TempDir()
src := `// Code generated by id-generate.go DO NOT EDIT.
package x
type SkipMeID string // @id:type
`
fp := writeTestFile(t, dir, "skip.go", src)
ids, pkg, err := processIDFile(dir, fp, false)
tst.AssertNoErr(t, err)
tst.AssertEqual(t, pkg, "")
tst.AssertEqual(t, len(ids), 0)
}
func TestProcessIDFileMissingFile(t *testing.T) {
_, _, err := processIDFile(t.TempDir(), filepath.Join(t.TempDir(), "does_not_exist.go"), false)
tst.AssertTrue(t, err != nil)
}
func TestGenerateIDSpecsEndToEnd(t *testing.T) {
dir := t.TempDir()
src1 := `package models
type UserID string // @id:type
type AnyID string // @id:type
`
writeTestFile(t, dir, "a_models.go", src1)
src2 := `package models
type OrderID string // @id:type
`
writeTestFile(t, dir, "b_models.go", src2)
dest := filepath.Join(dir, "id_gen.go")
err := GenerateIDSpecs(dir, dest, IDGenOptions{DebugOutput: langext.PFalse})
tst.AssertNoErr(t, err)
out, err := os.ReadFile(dest)
tst.AssertNoErr(t, err)
outStr := string(out)
tst.AssertTrue(t, strings.Contains(outStr, "package models"))
tst.AssertTrue(t, strings.Contains(outStr, "ChecksumIDGenerator"))
tst.AssertTrue(t, strings.Contains(outStr, "func NewUserID()"))
tst.AssertTrue(t, strings.Contains(outStr, "func NewOrderID()"))
tst.AssertTrue(t, strings.Contains(outStr, "func NewAnyID()"))
tst.AssertTrue(t, strings.Contains(outStr, "AsAny()"))
}
func TestGenerateIDSpecsIdempotentWhenUnchanged(t *testing.T) {
dir := t.TempDir()
src := `package models
type SomeID string // @id:type
`
writeTestFile(t, dir, "models.go", src)
dest := filepath.Join(dir, "id_gen.go")
err := GenerateIDSpecs(dir, dest, IDGenOptions{})
tst.AssertNoErr(t, err)
stat1, err := os.Stat(dest)
tst.AssertNoErr(t, err)
content1, err := os.ReadFile(dest)
tst.AssertNoErr(t, err)
err = GenerateIDSpecs(dir, dest, IDGenOptions{})
tst.AssertNoErr(t, err)
stat2, err := os.Stat(dest)
tst.AssertNoErr(t, err)
content2, err := os.ReadFile(dest)
tst.AssertNoErr(t, err)
tst.AssertEqual(t, stat1.ModTime().Equal(stat2.ModTime()), true)
tst.AssertEqual(t, string(content1), string(content2))
}
func TestGenerateIDSpecsRegeneratesAfterChange(t *testing.T) {
dir := t.TempDir()
src := `package models
type FirstID string // @id:type
`
fp := writeTestFile(t, dir, "models.go", src)
dest := filepath.Join(dir, "id_gen.go")
err := GenerateIDSpecs(dir, dest, IDGenOptions{})
tst.AssertNoErr(t, err)
content1, err := os.ReadFile(dest)
tst.AssertNoErr(t, err)
tst.AssertTrue(t, strings.Contains(string(content1), "FirstID"))
tst.AssertFalse(t, strings.Contains(string(content1), "SecondID"))
src2 := `package models
type FirstID string // @id:type
type SecondID string // @id:type
`
err = os.WriteFile(fp, []byte(src2), 0o644)
tst.AssertNoErr(t, err)
err = GenerateIDSpecs(dir, dest, IDGenOptions{})
tst.AssertNoErr(t, err)
content2, err := os.ReadFile(dest)
tst.AssertNoErr(t, err)
tst.AssertTrue(t, strings.Contains(string(content2), "SecondID"))
}
func TestGenerateIDSpecsErrorsWithoutPackage(t *testing.T) {
dir := t.TempDir()
src := `// Code generated by id-generate.go DO NOT EDIT.
package x
type SkippedID string // @id:type
`
writeTestFile(t, dir, "z.go", src)
dest := filepath.Join(dir, "id_gen.go")
err := GenerateIDSpecs(dir, dest, IDGenOptions{})
tst.AssertTrue(t, err != nil)
}
func TestGenerateIDSpecsMissingDir(t *testing.T) {
dir := filepath.Join(t.TempDir(), "definitely-missing")
err := GenerateIDSpecs(dir, filepath.Join(dir, "id_gen.go"), IDGenOptions{})
tst.AssertTrue(t, err != nil)
}
func TestFmtIDOutputContainsAllNames(t *testing.T) {
ids := []IDDef{
{File: "a.go", FileRelative: "a.go", Name: "AlphaID"},
{File: "b.go", FileRelative: "b.go", Name: "BetaID"},
}
out := fmtIDOutput("CHK_ABC", ids, "models")
tst.AssertTrue(t, strings.Contains(out, "package models"))
tst.AssertTrue(t, strings.Contains(out, "CHK_ABC"))
tst.AssertTrue(t, strings.Contains(out, "AlphaID"))
tst.AssertTrue(t, strings.Contains(out, "BetaID"))
}
+10 -3
View File
@@ -2,7 +2,7 @@ package cmdext
import (
"fmt"
"gogs.mikescher.com/BlackForestBytes/goext/langext"
"git.blackforestbytes.com/BlackForestBytes/goext/langext"
"time"
)
@@ -14,6 +14,7 @@ type CommandRunner struct {
listener []CommandListener
enforceExitCodes *[]int
enforceNoTimeout bool
enforceNoStderr bool
}
func Runner(program string) *CommandRunner {
@@ -25,6 +26,7 @@ func Runner(program string) *CommandRunner {
listener: make([]CommandListener, 0),
enforceExitCodes: nil,
enforceNoTimeout: false,
enforceNoStderr: false,
}
}
@@ -59,12 +61,12 @@ func (r *CommandRunner) Envs(env []string) *CommandRunner {
}
func (r *CommandRunner) EnsureExitcode(arg ...int) *CommandRunner {
r.enforceExitCodes = langext.Ptr(langext.ForceArray(arg))
r.enforceExitCodes = new(langext.ForceArray(arg))
return r
}
func (r *CommandRunner) FailOnExitCode() *CommandRunner {
r.enforceExitCodes = langext.Ptr([]int{0})
r.enforceExitCodes = new([]int{0})
return r
}
@@ -73,6 +75,11 @@ func (r *CommandRunner) FailOnTimeout() *CommandRunner {
return r
}
func (r *CommandRunner) FailOnStderr() *CommandRunner {
r.enforceNoStderr = true
return r
}
func (r *CommandRunner) Listen(lstr CommandListener) *CommandRunner {
r.listener = append(r.listener, lstr)
return r
+324
View File
@@ -0,0 +1,324 @@
package cmdext
import (
"reflect"
"testing"
"time"
)
func TestRunnerInit(t *testing.T) {
r := Runner("myprog")
if r == nil {
t.Fatalf("Runner returned nil")
}
if r.program != "myprog" {
t.Errorf("program == %v, want myprog", r.program)
}
if r.args == nil {
t.Errorf("args is nil, want empty slice")
}
if len(r.args) != 0 {
t.Errorf("len(args) == %v, want 0", len(r.args))
}
if r.env == nil {
t.Errorf("env is nil, want empty slice")
}
if len(r.env) != 0 {
t.Errorf("len(env) == %v, want 0", len(r.env))
}
if r.listener == nil {
t.Errorf("listener is nil, want empty slice")
}
if len(r.listener) != 0 {
t.Errorf("len(listener) == %v, want 0", len(r.listener))
}
if r.timeout != nil {
t.Errorf("timeout == %v, want nil", r.timeout)
}
if r.enforceExitCodes != nil {
t.Errorf("enforceExitCodes == %v, want nil", r.enforceExitCodes)
}
if r.enforceNoTimeout {
t.Errorf("enforceNoTimeout == true, want false")
}
if r.enforceNoStderr {
t.Errorf("enforceNoStderr == true, want false")
}
}
func TestArgSingle(t *testing.T) {
r := Runner("p").Arg("a")
if !reflect.DeepEqual(r.args, []string{"a"}) {
t.Errorf("args == %v, want [a]", r.args)
}
}
func TestArgMultiple(t *testing.T) {
r := Runner("p").Arg("a").Arg("b").Arg("c")
if !reflect.DeepEqual(r.args, []string{"a", "b", "c"}) {
t.Errorf("args == %v, want [a b c]", r.args)
}
}
func TestArgsAppendsAll(t *testing.T) {
r := Runner("p").Args([]string{"x", "y"}).Args([]string{"z"})
if !reflect.DeepEqual(r.args, []string{"x", "y", "z"}) {
t.Errorf("args == %v, want [x y z]", r.args)
}
}
func TestArgAndArgsMixed(t *testing.T) {
r := Runner("p").Arg("a").Args([]string{"b", "c"}).Arg("d")
if !reflect.DeepEqual(r.args, []string{"a", "b", "c", "d"}) {
t.Errorf("args == %v, want [a b c d]", r.args)
}
}
func TestArgsEmptySlice(t *testing.T) {
r := Runner("p").Args([]string{})
if len(r.args) != 0 {
t.Errorf("len(args) == %v, want 0", len(r.args))
}
}
func TestTimeoutSet(t *testing.T) {
d := 500 * time.Millisecond
r := Runner("p").Timeout(d)
if r.timeout == nil {
t.Fatalf("timeout is nil")
}
if *r.timeout != d {
t.Errorf("timeout == %v, want %v", *r.timeout, d)
}
}
func TestTimeoutOverride(t *testing.T) {
r := Runner("p").Timeout(1 * time.Second).Timeout(2 * time.Second)
if *r.timeout != 2*time.Second {
t.Errorf("timeout == %v, want 2s", *r.timeout)
}
}
func TestEnv(t *testing.T) {
r := Runner("p").Env("KEY", "VALUE")
if !reflect.DeepEqual(r.env, []string{"KEY=VALUE"}) {
t.Errorf("env == %v, want [KEY=VALUE]", r.env)
}
}
func TestEnvMultiple(t *testing.T) {
r := Runner("p").Env("A", "1").Env("B", "2")
if !reflect.DeepEqual(r.env, []string{"A=1", "B=2"}) {
t.Errorf("env == %v, want [A=1 B=2]", r.env)
}
}
func TestEnvWithEmptyValue(t *testing.T) {
r := Runner("p").Env("KEY", "")
if !reflect.DeepEqual(r.env, []string{"KEY="}) {
t.Errorf("env == %v, want [KEY=]", r.env)
}
}
func TestRawEnv(t *testing.T) {
r := Runner("p").RawEnv("FOO=BAR=BAZ")
if !reflect.DeepEqual(r.env, []string{"FOO=BAR=BAZ"}) {
t.Errorf("env == %v, want [FOO=BAR=BAZ]", r.env)
}
}
func TestEnvs(t *testing.T) {
r := Runner("p").Envs([]string{"A=1", "B=2"})
if !reflect.DeepEqual(r.env, []string{"A=1", "B=2"}) {
t.Errorf("env == %v, want [A=1 B=2]", r.env)
}
}
func TestEnvMixed(t *testing.T) {
r := Runner("p").Env("A", "1").RawEnv("B=2").Envs([]string{"C=3", "D=4"})
if !reflect.DeepEqual(r.env, []string{"A=1", "B=2", "C=3", "D=4"}) {
t.Errorf("env == %v, want [A=1 B=2 C=3 D=4]", r.env)
}
}
func TestEnsureExitcodeSingle(t *testing.T) {
r := Runner("p").EnsureExitcode(2)
if r.enforceExitCodes == nil {
t.Fatalf("enforceExitCodes is nil")
}
if !reflect.DeepEqual(*r.enforceExitCodes, []int{2}) {
t.Errorf("enforceExitCodes == %v, want [2]", *r.enforceExitCodes)
}
}
func TestEnsureExitcodeMultiple(t *testing.T) {
r := Runner("p").EnsureExitcode(0, 1, 2)
if r.enforceExitCodes == nil {
t.Fatalf("enforceExitCodes is nil")
}
if !reflect.DeepEqual(*r.enforceExitCodes, []int{0, 1, 2}) {
t.Errorf("enforceExitCodes == %v, want [0 1 2]", *r.enforceExitCodes)
}
}
func TestFailOnExitCode(t *testing.T) {
r := Runner("p").FailOnExitCode()
if r.enforceExitCodes == nil {
t.Fatalf("enforceExitCodes is nil")
}
if !reflect.DeepEqual(*r.enforceExitCodes, []int{0}) {
t.Errorf("enforceExitCodes == %v, want [0]", *r.enforceExitCodes)
}
}
func TestFailOnTimeoutFlag(t *testing.T) {
r := Runner("p")
if r.enforceNoTimeout {
t.Errorf("enforceNoTimeout was true before set")
}
r = r.FailOnTimeout()
if !r.enforceNoTimeout {
t.Errorf("enforceNoTimeout == false after FailOnTimeout()")
}
}
func TestFailOnStderrFlag(t *testing.T) {
r := Runner("p")
if r.enforceNoStderr {
t.Errorf("enforceNoStderr was true before set")
}
r = r.FailOnStderr()
if !r.enforceNoStderr {
t.Errorf("enforceNoStderr == false after FailOnStderr()")
}
}
func TestListen(t *testing.T) {
r := Runner("p").Listen(genericCommandListener{})
if len(r.listener) != 1 {
t.Errorf("len(listener) == %v, want 1", len(r.listener))
}
}
func TestListenMultiple(t *testing.T) {
r := Runner("p").
Listen(genericCommandListener{}).
Listen(genericCommandListener{}).
Listen(genericCommandListener{})
if len(r.listener) != 3 {
t.Errorf("len(listener) == %v, want 3", len(r.listener))
}
}
func TestListenStdoutAddsListener(t *testing.T) {
r := Runner("p").ListenStdout(func(string) {})
if len(r.listener) != 1 {
t.Errorf("len(listener) == %v, want 1", len(r.listener))
}
}
func TestListenStdoutForwardsCalls(t *testing.T) {
got := ""
r := Runner("p").ListenStdout(func(s string) { got = s })
if len(r.listener) != 1 {
t.Fatalf("len(listener) == %v, want 1", len(r.listener))
}
r.listener[0].ReadStdoutLine("hello")
if got != "hello" {
t.Errorf("listener got %q, want hello", got)
}
// non-stdout methods should not panic and should not affect state
r.listener[0].ReadStderrLine("nope")
r.listener[0].ReadRawStdout([]byte("raw"))
r.listener[0].ReadRawStderr([]byte("raw"))
r.listener[0].Finished(0)
r.listener[0].Timeout()
if got != "hello" {
t.Errorf("listener got mutated to %q, want hello", got)
}
}
func TestListenStderrAddsListener(t *testing.T) {
r := Runner("p").ListenStderr(func(string) {})
if len(r.listener) != 1 {
t.Errorf("len(listener) == %v, want 1", len(r.listener))
}
}
func TestListenStderrForwardsCalls(t *testing.T) {
got := ""
r := Runner("p").ListenStderr(func(s string) { got = s })
if len(r.listener) != 1 {
t.Fatalf("len(listener) == %v, want 1", len(r.listener))
}
r.listener[0].ReadStderrLine("oops")
if got != "oops" {
t.Errorf("listener got %q, want oops", got)
}
r.listener[0].ReadStdoutLine("nope")
if got != "oops" {
t.Errorf("listener got mutated to %q, want oops", got)
}
}
func TestChainReturnsSameInstance(t *testing.T) {
r := Runner("p")
if r.Arg("a") != r {
t.Errorf("Arg returned different instance")
}
if r.Args([]string{"b"}) != r {
t.Errorf("Args returned different instance")
}
if r.Timeout(time.Second) != r {
t.Errorf("Timeout returned different instance")
}
if r.Env("K", "V") != r {
t.Errorf("Env returned different instance")
}
if r.RawEnv("K=V") != r {
t.Errorf("RawEnv returned different instance")
}
if r.Envs([]string{"K=V"}) != r {
t.Errorf("Envs returned different instance")
}
if r.EnsureExitcode(0) != r {
t.Errorf("EnsureExitcode returned different instance")
}
if r.FailOnExitCode() != r {
t.Errorf("FailOnExitCode returned different instance")
}
if r.FailOnTimeout() != r {
t.Errorf("FailOnTimeout returned different instance")
}
if r.FailOnStderr() != r {
t.Errorf("FailOnStderr returned different instance")
}
if r.Listen(genericCommandListener{}) != r {
t.Errorf("Listen returned different instance")
}
if r.ListenStdout(func(string) {}) != r {
t.Errorf("ListenStdout returned different instance")
}
if r.ListenStderr(func(string) {}) != r {
t.Errorf("ListenStderr returned different instance")
}
}
func TestSeparateInstancesIndependent(t *testing.T) {
r1 := Runner("p1").Arg("a")
r2 := Runner("p2").Arg("b")
if r1.program != "p1" {
t.Errorf("r1.program == %v, want p1", r1.program)
}
if r2.program != "p2" {
t.Errorf("r2.program == %v, want p2", r2.program)
}
if !reflect.DeepEqual(r1.args, []string{"a"}) {
t.Errorf("r1.args == %v, want [a]", r1.args)
}
if !reflect.DeepEqual(r2.args, []string{"b"}) {
t.Errorf("r2.args == %v, want [b]", r2.args)
}
}
+48 -6
View File
@@ -2,15 +2,16 @@ package cmdext
import (
"errors"
"gogs.mikescher.com/BlackForestBytes/goext/langext"
"gogs.mikescher.com/BlackForestBytes/goext/mathext"
"gogs.mikescher.com/BlackForestBytes/goext/syncext"
"git.blackforestbytes.com/BlackForestBytes/goext/langext"
"git.blackforestbytes.com/BlackForestBytes/goext/mathext"
"git.blackforestbytes.com/BlackForestBytes/goext/syncext"
"os/exec"
"time"
)
var ErrExitCode = errors.New("process exited with an unexpected exitcode")
var ErrTimeout = errors.New("process did not exit after the specified timeout")
var ErrStderrPrint = errors.New("process did print to stderr stream")
type CommandResult struct {
StdOut string
@@ -36,7 +37,7 @@ func run(opt CommandRunner) (CommandResult, error) {
}
preader := pipeReader{
lineBufferSize: langext.Ptr(128 * 1024 * 1024), // 128MB max size of a single line, is hopefully enough....
lineBufferSize: new(128 * 1024 * 1024), // 128MB max size of a single line, is hopefully enough....
stdout: stdoutPipe,
stderr: stderrPipe,
}
@@ -53,12 +54,27 @@ func run(opt CommandRunner) (CommandResult, error) {
err error
}
stderrFailChan := make(chan bool)
outputChan := make(chan resultObj)
go func() {
// we need to first fully read the pipes and then call Wait
// see https://pkg.go.dev/os/exec#Cmd.StdoutPipe
stdout, stderr, stdcombined, err := preader.Read(opt.listener)
listener := make([]CommandListener, 0)
listener = append(listener, opt.listener...)
if opt.enforceNoStderr {
listener = append(listener, genericCommandListener{
_readRawStderr: new(func(v []byte) {
if len(v) > 0 {
stderrFailChan <- true
}
}),
})
}
stdout, stderr, stdcombined, err := preader.Read(listener)
if err != nil {
outputChan <- resultObj{stdout, stderr, stdcombined, err}
_ = cmd.Process.Kill()
@@ -115,8 +131,34 @@ func run(opt CommandRunner) (CommandResult, error) {
return res, nil
}
case <-stderrFailChan:
_ = cmd.Process.Kill()
if fallback, ok := syncext.ReadChannelWithTimeout(outputChan, 32*time.Millisecond); ok {
// most of the time the cmd.Process.Kill() should also have finished the pipereader
// and we can at least return the already collected stdout, stderr, etc
res := CommandResult{
StdOut: fallback.stdout,
StdErr: fallback.stderr,
StdCombined: fallback.stdcombined,
ExitCode: -1,
CommandTimedOut: false,
}
return res, ErrStderrPrint
} else {
res := CommandResult{
StdOut: "",
StdErr: "",
StdCombined: "",
ExitCode: -1,
CommandTimedOut: false,
}
return res, ErrStderrPrint
}
case outobj := <-outputChan:
if exiterr, ok := outobj.err.(*exec.ExitError); ok {
var exiterr *exec.ExitError
if errors.As(outobj.err, &exiterr) {
excode := exiterr.ExitCode()
for _, lstr := range opt.listener {
lstr.Finished(excode)
+37 -12
View File
@@ -1,6 +1,7 @@
package cmdext
import (
"errors"
"fmt"
"testing"
"time"
@@ -32,7 +33,7 @@ func TestStdout(t *testing.T) {
func TestStderr(t *testing.T) {
res1, err := Runner("python").Arg("-c").Arg("import sys; print(\"error\", file=sys.stderr, end='')").Run()
res1, err := Runner("python3").Arg("-c").Arg("import sys; print(\"error\", file=sys.stderr, end='')").Run()
if err != nil {
t.Errorf("%v", err)
}
@@ -55,7 +56,7 @@ func TestStderr(t *testing.T) {
}
func TestStdcombined(t *testing.T) {
res1, err := Runner("python").
res1, err := Runner("python3").
Arg("-c").
Arg("import sys; import time; print(\"1\", file=sys.stderr, flush=True); time.sleep(0.1); print(\"2\", file=sys.stdout, flush=True); time.sleep(0.1); print(\"3\", file=sys.stderr, flush=True)").
Run()
@@ -81,7 +82,7 @@ func TestStdcombined(t *testing.T) {
}
func TestPartialRead(t *testing.T) {
res1, err := Runner("python").
res1, err := Runner("python3").
Arg("-c").
Arg("import sys; import time; print(\"first message\", flush=True); time.sleep(5); print(\"cant see me\", flush=True);").
Timeout(100 * time.Millisecond).
@@ -105,7 +106,7 @@ func TestPartialRead(t *testing.T) {
}
func TestPartialReadStderr(t *testing.T) {
res1, err := Runner("python").
res1, err := Runner("python3").
Arg("-c").
Arg("import sys; import time; print(\"first message\", file=sys.stderr, flush=True); time.sleep(5); print(\"cant see me\", file=sys.stderr, flush=True);").
Timeout(100 * time.Millisecond).
@@ -130,7 +131,7 @@ func TestPartialReadStderr(t *testing.T) {
func TestReadUnflushedStdout(t *testing.T) {
res1, err := Runner("python").Arg("-c").Arg("import sys; print(\"message101\", file=sys.stdout, end='')").Run()
res1, err := Runner("python3").Arg("-c").Arg("import sys; print(\"message101\", file=sys.stdout, end='')").Run()
if err != nil {
t.Errorf("%v", err)
}
@@ -154,7 +155,7 @@ func TestReadUnflushedStdout(t *testing.T) {
func TestReadUnflushedStderr(t *testing.T) {
res1, err := Runner("python").Arg("-c").Arg("import sys; print(\"message101\", file=sys.stderr, end='')").Run()
res1, err := Runner("python3").Arg("-c").Arg("import sys; print(\"message101\", file=sys.stderr, end='')").Run()
if err != nil {
t.Errorf("%v", err)
}
@@ -179,7 +180,7 @@ func TestReadUnflushedStderr(t *testing.T) {
func TestPartialReadUnflushed(t *testing.T) {
t.SkipNow()
res1, err := Runner("python").
res1, err := Runner("python3").
Arg("-c").
Arg("import sys; import time; print(\"first message\", end=''); time.sleep(5); print(\"cant see me\", end='');").
Timeout(100 * time.Millisecond).
@@ -205,7 +206,7 @@ func TestPartialReadUnflushed(t *testing.T) {
func TestPartialReadUnflushedStderr(t *testing.T) {
t.SkipNow()
res1, err := Runner("python").
res1, err := Runner("python3").
Arg("-c").
Arg("import sys; import time; print(\"first message\", file=sys.stderr, end=''); time.sleep(5); print(\"cant see me\", file=sys.stderr, end='');").
Timeout(100 * time.Millisecond).
@@ -230,7 +231,7 @@ func TestPartialReadUnflushedStderr(t *testing.T) {
func TestListener(t *testing.T) {
res1, err := Runner("python").
res1, err := Runner("python3").
Arg("-c").
Arg("import sys;" +
"import time;" +
@@ -263,7 +264,7 @@ func TestListener(t *testing.T) {
func TestLongStdout(t *testing.T) {
res1, err := Runner("python").
res1, err := Runner("python3").
Arg("-c").
Arg("import sys; import time; print(\"X\" * 125001 + \"\\n\"); print(\"Y\" * 125001 + \"\\n\"); print(\"Z\" * 125001 + \"\\n\");").
Timeout(5000 * time.Millisecond).
@@ -289,16 +290,40 @@ func TestLongStdout(t *testing.T) {
func TestFailOnTimeout(t *testing.T) {
_, err := Runner("sleep").Arg("2").Timeout(200 * time.Millisecond).FailOnTimeout().Run()
if err != ErrTimeout {
if !errors.Is(err, ErrTimeout) {
t.Errorf("wrong err := %v", err)
}
}
func TestFailOnStderr(t *testing.T) {
res1, err := Runner("python3").Arg("-c").Arg("import sys; print(\"error\", file=sys.stderr, end='')").FailOnStderr().Run()
if err == nil {
t.Errorf("no err")
}
if res1.CommandTimedOut {
t.Errorf("Timeout")
}
if res1.ExitCode != -1 {
t.Errorf("res1.ExitCode == %v", res1.ExitCode)
}
if res1.StdErr != "error" {
t.Errorf("res1.StdErr == '%v'", res1.StdErr)
}
if res1.StdOut != "" {
t.Errorf("res1.StdOut == '%v'", res1.StdOut)
}
if res1.StdCombined != "error\n" {
t.Errorf("res1.StdCombined == '%v'", res1.StdCombined)
}
}
func TestFailOnExitcode(t *testing.T) {
_, err := Runner("false").Timeout(200 * time.Millisecond).FailOnExitCode().Run()
if err != ErrExitCode {
if !errors.Is(err, ErrExitCode) {
t.Errorf("wrong err := %v", err)
}
+143
View File
@@ -0,0 +1,143 @@
package cmdext
import (
"reflect"
"testing"
)
func TestGenericListenerEmptyDoesNotPanic(t *testing.T) {
l := genericCommandListener{}
l.ReadRawStdout([]byte("x"))
l.ReadRawStderr([]byte("x"))
l.ReadStdoutLine("x")
l.ReadStderrLine("x")
l.Finished(0)
l.Timeout()
}
func TestGenericListenerReadRawStdout(t *testing.T) {
var got []byte
fn := func(b []byte) { got = append(got, b...) }
l := genericCommandListener{_readRawStdout: &fn}
l.ReadRawStdout([]byte("hello"))
l.ReadRawStdout([]byte(" world"))
if string(got) != "hello world" {
t.Errorf("got %q, want %q", string(got), "hello world")
}
}
func TestGenericListenerReadRawStderr(t *testing.T) {
var got []byte
fn := func(b []byte) { got = append(got, b...) }
l := genericCommandListener{_readRawStderr: &fn}
l.ReadRawStderr([]byte("err"))
if string(got) != "err" {
t.Errorf("got %q, want %q", string(got), "err")
}
}
func TestGenericListenerReadStdoutLine(t *testing.T) {
var got []string
fn := func(s string) { got = append(got, s) }
l := genericCommandListener{_readStdoutLine: &fn}
l.ReadStdoutLine("line1")
l.ReadStdoutLine("line2")
if !reflect.DeepEqual(got, []string{"line1", "line2"}) {
t.Errorf("got %v, want [line1 line2]", got)
}
}
func TestGenericListenerReadStderrLine(t *testing.T) {
var got []string
fn := func(s string) { got = append(got, s) }
l := genericCommandListener{_readStderrLine: &fn}
l.ReadStderrLine("line1")
l.ReadStderrLine("line2")
if !reflect.DeepEqual(got, []string{"line1", "line2"}) {
t.Errorf("got %v, want [line1 line2]", got)
}
}
func TestGenericListenerFinished(t *testing.T) {
var got int
called := false
fn := func(v int) { got = v; called = true }
l := genericCommandListener{_finished: &fn}
l.Finished(42)
if !called {
t.Errorf("Finished callback was not called")
}
if got != 42 {
t.Errorf("got %v, want 42", got)
}
}
func TestGenericListenerTimeout(t *testing.T) {
called := false
fn := func() { called = true }
l := genericCommandListener{_timeout: &fn}
l.Timeout()
if !called {
t.Errorf("Timeout callback was not called")
}
}
func TestGenericListenerOnlySpecifiedCalled(t *testing.T) {
stdoutCalled := false
stderrCalled := false
stdoutFn := func(string) { stdoutCalled = true }
stderrFn := func(string) { stderrCalled = true }
l := genericCommandListener{_readStdoutLine: &stdoutFn, _readStderrLine: &stderrFn}
l.ReadStdoutLine("x")
if !stdoutCalled {
t.Errorf("stdout callback not called")
}
if stderrCalled {
t.Errorf("stderr callback called when it shouldn't be")
}
stdoutCalled = false
l.ReadStderrLine("x")
if stdoutCalled {
t.Errorf("stdout callback called when it shouldn't be")
}
if !stderrCalled {
t.Errorf("stderr callback not called")
}
// these have no callbacks set; should be no-ops
l.ReadRawStdout([]byte("x"))
l.ReadRawStderr([]byte("x"))
l.Finished(0)
l.Timeout()
}
func TestGenericListenerImplementsCommandListener(t *testing.T) {
var _ CommandListener = genericCommandListener{}
}
func TestGenericListenerEmptyByteSlice(t *testing.T) {
calls := 0
fn := func(b []byte) { calls++ }
l := genericCommandListener{_readRawStdout: &fn}
l.ReadRawStdout([]byte{})
l.ReadRawStdout(nil)
if calls != 2 {
t.Errorf("calls == %v, want 2", calls)
}
}
+19 -22
View File
@@ -2,8 +2,9 @@ package cmdext
import (
"bufio"
"gogs.mikescher.com/BlackForestBytes/goext/syncext"
"git.blackforestbytes.com/BlackForestBytes/goext/syncext"
"io"
"strings"
"sync"
)
@@ -29,24 +30,24 @@ func (pr *pipeReader) Read(listener []CommandListener) (string, string, string,
wg.Add(1)
stdoutBufferReader, stdoutBufferWriter := io.Pipe()
stdout := ""
var stdout strings.Builder
go func() {
buf := make([]byte, 128)
for true {
n, out := pr.stdout.Read(buf)
for {
n, err := pr.stdout.Read(buf)
if n > 0 {
txt := string(buf[:n])
stdout += txt
stdout.WriteString(txt)
_, _ = stdoutBufferWriter.Write(buf[:n])
for _, lstr := range listener {
lstr.ReadRawStdout(buf[:n])
}
}
if out == io.EOF {
if err == io.EOF {
break
}
if out != nil {
errch <- out
if err != nil {
errch <- err
break
}
}
@@ -58,15 +59,15 @@ func (pr *pipeReader) Read(listener []CommandListener) (string, string, string,
wg.Add(1)
stderrBufferReader, stderrBufferWriter := io.Pipe()
stderr := ""
var stderr strings.Builder
go func() {
buf := make([]byte, 128)
for true {
for {
n, err := pr.stderr.Read(buf)
if n > 0 {
txt := string(buf[:n])
stderr += txt
stderr.WriteString(txt)
_, _ = stderrBufferWriter.Write(buf[:n])
for _, lstr := range listener {
lstr.ReadRawStderr(buf[:n])
@@ -88,8 +89,7 @@ func (pr *pipeReader) Read(listener []CommandListener) (string, string, string,
// [3] collect stdout line-by-line
wg.Add(1)
go func() {
wg.Go(func() {
scanner := bufio.NewScanner(stdoutBufferReader)
if pr.lineBufferSize != nil {
scanner.Buffer([]byte{}, *pr.lineBufferSize)
@@ -105,13 +105,11 @@ func (pr *pipeReader) Read(listener []CommandListener) (string, string, string,
errch <- err
}
combch <- combevt{"", true}
wg.Done()
}()
})
// [4] collect stderr line-by-line
wg.Add(1)
go func() {
wg.Go(func() {
scanner := bufio.NewScanner(stderrBufferReader)
if pr.lineBufferSize != nil {
scanner.Buffer([]byte{}, *pr.lineBufferSize)
@@ -127,13 +125,12 @@ func (pr *pipeReader) Read(listener []CommandListener) (string, string, string,
errch <- err
}
combch <- combevt{"", true}
wg.Done()
}()
})
// [5] combine stdcombined
wg.Add(1)
stdcombined := ""
var stdcombined strings.Builder
go func() {
stopctr := 0
for stopctr < 2 {
@@ -141,7 +138,7 @@ func (pr *pipeReader) Read(listener []CommandListener) (string, string, string,
if vvv.stop {
stopctr++
} else {
stdcombined += vvv.line + "\n" // this comes from bufio.Scanner and has no newlines...
stdcombined.WriteString(vvv.line + "\n") // this comes from bufio.Scanner and has no newlines...
}
}
wg.Done()
@@ -154,5 +151,5 @@ func (pr *pipeReader) Read(listener []CommandListener) (string, string, string,
return "", "", "", err
}
return stdout, stderr, stdcombined, nil
return stdout.String(), stderr.String(), stdcombined.String(), nil
}
+17 -20
View File
@@ -3,7 +3,7 @@ package confext
import (
"errors"
"fmt"
"gogs.mikescher.com/BlackForestBytes/goext/timeext"
"git.blackforestbytes.com/BlackForestBytes/goext/timeext"
"math/bits"
"os"
"reflect"
@@ -41,12 +41,12 @@ func processEnvOverrides(rval reflect.Value, delim string, prefix string) error
continue
}
if rvfield.Kind() == reflect.Struct {
envkey, found := rsfield.Tag.Lookup("env")
if !found || envkey == "-" {
continue
}
envkey, found := rsfield.Tag.Lookup("env")
if !found || envkey == "-" {
continue
}
if rvfield.Kind() == reflect.Struct && rvfield.Type() != reflect.TypeOf(time.UnixMilli(0)) {
subPrefix := prefix
if envkey != "" {
@@ -57,10 +57,7 @@ func processEnvOverrides(rval reflect.Value, delim string, prefix string) error
if err != nil {
return err
}
}
envkey := rsfield.Tag.Get("env")
if envkey == "" || envkey == "-" {
continue
}
@@ -104,11 +101,11 @@ func processEnvOverrides(rval reflect.Value, delim string, prefix string) error
}
func parseEnvToValue(envval string, fullEnvKey string, rvtype reflect.Type) (reflect.Value, error) {
if rvtype == reflect.TypeOf("") {
if rvtype == reflect.TypeFor[string]() {
return reflect.ValueOf(envval), nil
} else if rvtype == reflect.TypeOf(int(0)) {
} else if rvtype == reflect.TypeFor[int]() {
envint, err := strconv.ParseInt(envval, 10, bits.UintSize)
if err != nil {
@@ -117,7 +114,7 @@ func parseEnvToValue(envval string, fullEnvKey string, rvtype reflect.Type) (ref
return reflect.ValueOf(int(envint)), nil
} else if rvtype == reflect.TypeOf(int64(0)) {
} else if rvtype == reflect.TypeFor[int64]() {
envint, err := strconv.ParseInt(envval, 10, 64)
if err != nil {
@@ -126,7 +123,7 @@ func parseEnvToValue(envval string, fullEnvKey string, rvtype reflect.Type) (ref
return reflect.ValueOf(int64(envint)), nil
} else if rvtype == reflect.TypeOf(int32(0)) {
} else if rvtype == reflect.TypeFor[int32]() {
envint, err := strconv.ParseInt(envval, 10, 32)
if err != nil {
@@ -135,7 +132,7 @@ func parseEnvToValue(envval string, fullEnvKey string, rvtype reflect.Type) (ref
return reflect.ValueOf(int32(envint)), nil
} else if rvtype == reflect.TypeOf(int8(0)) {
} else if rvtype == reflect.TypeFor[int8]() {
envint, err := strconv.ParseInt(envval, 10, 8)
if err != nil {
@@ -144,7 +141,7 @@ func parseEnvToValue(envval string, fullEnvKey string, rvtype reflect.Type) (ref
return reflect.ValueOf(int8(envint)), nil
} else if rvtype == reflect.TypeOf(time.Duration(0)) {
} else if rvtype == reflect.TypeFor[time.Duration]() {
dur, err := timeext.ParseDurationShortString(envval)
if err != nil {
@@ -162,7 +159,7 @@ func parseEnvToValue(envval string, fullEnvKey string, rvtype reflect.Type) (ref
return reflect.ValueOf(tim), nil
} else if rvtype.ConvertibleTo(reflect.TypeOf(int(0))) {
} else if rvtype.ConvertibleTo(reflect.TypeFor[int]()) {
envint, err := strconv.ParseInt(envval, 10, 8)
if err != nil {
@@ -173,21 +170,21 @@ func parseEnvToValue(envval string, fullEnvKey string, rvtype reflect.Type) (ref
return envcvl, nil
} else if rvtype.ConvertibleTo(reflect.TypeOf(false)) {
} else if rvtype.ConvertibleTo(reflect.TypeFor[bool]()) {
if strings.TrimSpace(strings.ToLower(envval)) == "true" {
return reflect.ValueOf(true).Convert(rvtype), nil
} else if strings.TrimSpace(strings.ToLower(envval)) == "false" {
return reflect.ValueOf(true).Convert(rvtype), nil
} else if strings.TrimSpace(strings.ToLower(envval)) == "1" {
return reflect.ValueOf(false).Convert(rvtype), nil
} else if strings.TrimSpace(strings.ToLower(envval)) == "1" {
return reflect.ValueOf(true).Convert(rvtype), nil
} else if strings.TrimSpace(strings.ToLower(envval)) == "0" {
return reflect.ValueOf(false).Convert(rvtype), nil
} else {
return reflect.Value{}, errors.New(fmt.Sprintf("Failed to parse env-config variable '%s' to <%s, ,bool> (value := '%s')", rvtype.Name(), fullEnvKey, envval))
}
} else if rvtype.ConvertibleTo(reflect.TypeOf("")) {
} else if rvtype.ConvertibleTo(reflect.TypeFor[string]()) {
envcvl := reflect.ValueOf(envval).Convert(rvtype)
return envcvl, nil
+390
View File
@@ -0,0 +1,390 @@
package confext
import (
"git.blackforestbytes.com/BlackForestBytes/goext/tst"
"testing"
"time"
)
func TestApplyEnvOverridesPrefix(t *testing.T) {
type testdata struct {
V1 int `env:"V1"`
V2 string `env:"V2"`
}
data := testdata{V1: 1, V2: "x"}
t.Setenv("MYAPP_V1", "42")
t.Setenv("MYAPP_V2", "hello")
t.Setenv("V1", "111")
t.Setenv("V2", "noprefix")
err := ApplyEnvOverrides("MYAPP_", &data, ".")
tst.AssertNoErr(t, err)
tst.AssertEqual(t, data.V1, 42)
tst.AssertEqual(t, data.V2, "hello")
}
func TestApplyEnvOverridesUnexportedFieldsIgnored(t *testing.T) {
type testdata struct {
V1 int `env:"TEST_V1"`
v2 int `env:"TEST_V2"`
}
data := testdata{V1: 1, v2: 2}
t.Setenv("TEST_V1", "11")
t.Setenv("TEST_V2", "22")
err := ApplyEnvOverrides("", &data, ".")
tst.AssertNoErr(t, err)
tst.AssertEqual(t, data.V1, 11)
tst.AssertEqual(t, data.v2, 2)
}
func TestApplyEnvOverridesNoEnvTagIgnored(t *testing.T) {
type testdata struct {
V1 int `env:"TEST_V1"`
V2 int ``
}
data := testdata{V1: 1, V2: 2}
t.Setenv("TEST_V1", "11")
t.Setenv("V2", "22")
err := ApplyEnvOverrides("", &data, ".")
tst.AssertNoErr(t, err)
tst.AssertEqual(t, data.V1, 11)
tst.AssertEqual(t, data.V2, 2)
}
func TestApplyEnvOverridesDashTagIgnored(t *testing.T) {
type testdata struct {
V1 int `env:"TEST_V1"`
V2 string `env:"-"`
}
data := testdata{V1: 1, V2: "no"}
t.Setenv("TEST_V1", "11")
t.Setenv("-", "yes")
err := ApplyEnvOverrides("", &data, ".")
tst.AssertNoErr(t, err)
tst.AssertEqual(t, data.V1, 11)
tst.AssertEqual(t, data.V2, "no")
}
func TestApplyEnvOverridesEnvNotSetKeepsValue(t *testing.T) {
type testdata struct {
V1 int `env:"NOT_SET_INT_KEY_XYZ"`
V2 string `env:"NOT_SET_STR_KEY_XYZ"`
}
data := testdata{V1: 7, V2: "keep"}
err := ApplyEnvOverrides("", &data, ".")
tst.AssertNoErr(t, err)
tst.AssertEqual(t, data.V1, 7)
tst.AssertEqual(t, data.V2, "keep")
}
func TestApplyEnvOverridesBoolVariants(t *testing.T) {
type testdata struct {
B1 bool `env:"B1"`
B2 bool `env:"B2"`
B3 bool `env:"B3"`
B4 bool `env:"B4"`
B5 bool `env:"B5"`
B6 bool `env:"B6"`
}
data := testdata{}
t.Setenv("B1", "true")
t.Setenv("B2", "false")
t.Setenv("B3", "1")
t.Setenv("B4", "0")
t.Setenv("B5", " TRUE ")
t.Setenv("B6", "FaLsE")
err := ApplyEnvOverrides("", &data, ".")
tst.AssertNoErr(t, err)
tst.AssertEqual(t, data.B1, true)
tst.AssertEqual(t, data.B2, false)
tst.AssertEqual(t, data.B3, true)
tst.AssertEqual(t, data.B4, false)
tst.AssertEqual(t, data.B5, true)
tst.AssertEqual(t, data.B6, false)
}
func TestApplyEnvOverridesInvalidIntReturnsError(t *testing.T) {
type testdata struct {
V1 int `env:"BAD_INT"`
}
data := testdata{}
t.Setenv("BAD_INT", "not_a_number")
err := ApplyEnvOverrides("", &data, ".")
if err == nil {
t.Errorf("expected error for invalid int, got nil")
}
}
func TestApplyEnvOverridesInvalidInt8ReturnsError(t *testing.T) {
type testdata struct {
V1 int8 `env:"BAD_INT8"`
}
data := testdata{}
t.Setenv("BAD_INT8", "9999")
err := ApplyEnvOverrides("", &data, ".")
if err == nil {
t.Errorf("expected error for invalid int8, got nil")
}
}
func TestApplyEnvOverridesInvalidInt32ReturnsError(t *testing.T) {
type testdata struct {
V1 int32 `env:"BAD_INT32"`
}
data := testdata{}
t.Setenv("BAD_INT32", "not_an_int32")
err := ApplyEnvOverrides("", &data, ".")
if err == nil {
t.Errorf("expected error for invalid int32, got nil")
}
}
func TestApplyEnvOverridesInvalidInt64ReturnsError(t *testing.T) {
type testdata struct {
V1 int64 `env:"BAD_INT64"`
}
data := testdata{}
t.Setenv("BAD_INT64", "not_an_int64")
err := ApplyEnvOverrides("", &data, ".")
if err == nil {
t.Errorf("expected error for invalid int64, got nil")
}
}
func TestApplyEnvOverridesInvalidDurationReturnsError(t *testing.T) {
type testdata struct {
V1 time.Duration `env:"BAD_DUR"`
}
data := testdata{}
t.Setenv("BAD_DUR", "not_a_duration")
err := ApplyEnvOverrides("", &data, ".")
if err == nil {
t.Errorf("expected error for invalid duration, got nil")
}
}
func TestApplyEnvOverridesInvalidTimeReturnsError(t *testing.T) {
type testdata struct {
V1 time.Time `env:"BAD_TIME"`
}
data := testdata{}
t.Setenv("BAD_TIME", "not_a_time")
err := ApplyEnvOverrides("", &data, ".")
if err == nil {
t.Errorf("expected error for invalid time, got nil")
}
}
func TestApplyEnvOverridesInvalidBoolReturnsError(t *testing.T) {
type testdata struct {
V1 bool `env:"BAD_BOOL"`
}
data := testdata{}
t.Setenv("BAD_BOOL", "yesno")
err := ApplyEnvOverrides("", &data, ".")
if err == nil {
t.Errorf("expected error for invalid bool, got nil")
}
}
func TestApplyEnvOverridesUnsupportedTypeReturnsError(t *testing.T) {
type testdata struct {
V1 []int `env:"UNSUPPORTED"`
}
data := testdata{}
t.Setenv("UNSUPPORTED", "1,2,3")
err := ApplyEnvOverrides("", &data, ".")
if err == nil {
t.Errorf("expected error for unsupported type, got nil")
}
}
func TestApplyEnvOverridesFloatUnsupportedReturnsError(t *testing.T) {
type testdata struct {
V1 float64 `env:"UNSUPPORTED_FLOAT"`
}
data := testdata{}
t.Setenv("UNSUPPORTED_FLOAT", "1.5")
err := ApplyEnvOverrides("", &data, ".")
if err == nil {
t.Errorf("expected error for float64, got nil")
}
}
func TestApplyEnvOverridesPointerInvalidReturnsError(t *testing.T) {
type testdata struct {
V1 *int `env:"PTR_BAD_INT"`
}
data := testdata{}
t.Setenv("PTR_BAD_INT", "not_a_number")
err := ApplyEnvOverrides("", &data, ".")
if err == nil {
t.Errorf("expected error for invalid pointer int, got nil")
}
}
func TestApplyEnvOverridesPointerNotSetStaysNil(t *testing.T) {
type testdata struct {
V1 *int `env:"PTR_NOT_SET_KEY_ABC"`
V2 *string `env:"PTR_NOT_SET_KEY_DEF"`
}
data := testdata{}
err := ApplyEnvOverrides("", &data, ".")
tst.AssertNoErr(t, err)
if data.V1 != nil {
t.Errorf("expected V1 to remain nil, got %v", *data.V1)
}
if data.V2 != nil {
t.Errorf("expected V2 to remain nil, got %v", *data.V2)
}
}
func TestApplyEnvOverridesAliasBool(t *testing.T) {
type aliasbool bool
type testdata struct {
V1 aliasbool `env:"ALIAS_BOOL"`
}
data := testdata{}
t.Setenv("ALIAS_BOOL", "true")
err := ApplyEnvOverrides("", &data, ".")
tst.AssertNoErr(t, err)
tst.AssertEqual(t, data.V1, aliasbool(true))
}
func TestApplyEnvOverridesNestedRecursiveError(t *testing.T) {
type subdata struct {
V1 int `env:"V1"`
}
type testdata struct {
Sub subdata `env:"SUB"`
}
data := testdata{}
t.Setenv("SUB.V1", "not_a_number")
err := ApplyEnvOverrides("", &data, ".")
if err == nil {
t.Errorf("expected error from nested struct invalid value, got nil")
}
}
func TestApplyEnvOverridesTimeFieldInsideStructIsParsed(t *testing.T) {
type testdata struct {
T time.Time `env:"MYTIME"`
}
data := testdata{}
t.Setenv("MYTIME", "2023-01-02T03:04:05Z")
err := ApplyEnvOverrides("", &data, ".")
tst.AssertNoErr(t, err)
tst.AssertEqual(t, data.T.Equal(time.Date(2023, 1, 2, 3, 4, 5, 0, time.UTC)), true)
}
func TestApplyEnvOverridesPointerStringAlias(t *testing.T) {
type aliasstr string
type testdata struct {
V1 *aliasstr `env:"PTR_ALIAS_STR"`
}
data := testdata{}
t.Setenv("PTR_ALIAS_STR", "hello")
err := ApplyEnvOverrides("", &data, ".")
tst.AssertNoErr(t, err)
if data.V1 == nil {
t.Fatalf("expected V1 to be set")
}
tst.AssertEqual(t, *data.V1, aliasstr("hello"))
}
func TestApplyEnvOverridesEmptyEnvTagOnSubstruct(t *testing.T) {
type subdata struct {
V1 int `env:"INNER"`
}
type testdata struct {
Sub subdata `env:""`
}
data := testdata{}
t.Setenv("INNER", "55")
err := ApplyEnvOverrides("PRE_", &data, "_")
tst.AssertNoErr(t, err)
tst.AssertEqual(t, data.Sub.V1, 0)
t.Setenv("PRE_INNER", "77")
err = ApplyEnvOverrides("PRE_", &data, "_")
tst.AssertNoErr(t, err)
tst.AssertEqual(t, data.Sub.V1, 77)
}
+2 -2
View File
@@ -1,8 +1,8 @@
package confext
import (
"gogs.mikescher.com/BlackForestBytes/goext/timeext"
"gogs.mikescher.com/BlackForestBytes/goext/tst"
"git.blackforestbytes.com/BlackForestBytes/goext/timeext"
"git.blackforestbytes.com/BlackForestBytes/goext/tst"
"testing"
"time"
)
+139
View File
@@ -0,0 +1,139 @@
package cryptext
import (
"git.blackforestbytes.com/BlackForestBytes/goext/tst"
"strings"
"testing"
)
func TestAESSimpleEmptyData(t *testing.T) {
pw := []byte("password")
enc, err := EncryptAESSimple(pw, []byte{}, 256)
tst.AssertNoErr(t, err)
tst.AssertNotEqual(t, enc, "")
dec, err := DecryptAESSimple(pw, enc)
tst.AssertNoErr(t, err)
tst.AssertEqual(t, len(dec), 0)
}
func TestAESSimpleEmptyPassword(t *testing.T) {
pw := []byte{}
plain := []byte("some content")
enc, err := EncryptAESSimple(pw, plain, 256)
tst.AssertNoErr(t, err)
dec, err := DecryptAESSimple(pw, enc)
tst.AssertNoErr(t, err)
tst.AssertEqual(t, string(dec), string(plain))
}
func TestAESSimpleWrongPassword(t *testing.T) {
plain := []byte("Hello World")
enc, err := EncryptAESSimple([]byte("right"), plain, 256)
tst.AssertNoErr(t, err)
_, err = DecryptAESSimple([]byte("wrong"), enc)
if err == nil {
t.Errorf("expected error when decrypting with wrong password")
}
}
func TestAESSimpleInvalidBase32(t *testing.T) {
_, err := DecryptAESSimple([]byte("pw"), "!!!not-base32!!!")
if err == nil {
t.Errorf("expected error on invalid base32 input")
}
}
func TestAESSimpleInvalidJSON(t *testing.T) {
// "AAAAAAAA" decodes to valid base32 but not valid JSON
_, err := DecryptAESSimple([]byte("pw"), "AAAAAAAA")
if err == nil {
t.Errorf("expected error on invalid JSON payload")
}
}
func TestAESSimpleEmptyEncText(t *testing.T) {
_, err := DecryptAESSimple([]byte("pw"), "")
if err == nil {
t.Errorf("expected error on empty text")
}
}
func TestAESSimpleLargeData(t *testing.T) {
pw := []byte("hunter12")
plain := []byte(strings.Repeat("ABCDEFGHIJ", 1024))
enc, err := EncryptAESSimple(pw, plain, 256)
tst.AssertNoErr(t, err)
dec, err := DecryptAESSimple(pw, enc)
tst.AssertNoErr(t, err)
tst.AssertEqual(t, string(dec), string(plain))
}
func TestAESSimpleBinaryData(t *testing.T) {
pw := []byte("hunter12")
plain := []byte{0x00, 0x01, 0x02, 0x7F, 0x80, 0xFE, 0xFF, 0x00, 0xAA, 0x55}
enc, err := EncryptAESSimple(pw, plain, 256)
tst.AssertNoErr(t, err)
dec, err := DecryptAESSimple(pw, enc)
tst.AssertNoErr(t, err)
tst.AssertArrayEqual(t, dec, plain)
}
func TestAESSimpleDifferentRoundsForEachCall(t *testing.T) {
pw := []byte("hunter12")
plain := []byte("Hello")
enc1, err := EncryptAESSimple(pw, plain, 256)
tst.AssertNoErr(t, err)
enc2, err := EncryptAESSimple(pw, plain, 256)
tst.AssertNoErr(t, err)
// Two separate encrypt calls on same plaintext should differ (random salt + IV)
tst.AssertNotEqual(t, enc1, enc2)
// Both should decrypt back to the same plaintext
d1, err := DecryptAESSimple(pw, enc1)
tst.AssertNoErr(t, err)
d2, err := DecryptAESSimple(pw, enc2)
tst.AssertNoErr(t, err)
tst.AssertEqual(t, string(d1), string(plain))
tst.AssertEqual(t, string(d2), string(plain))
}
func TestAESSimpleVariableRounds(t *testing.T) {
pw := []byte("hunter12")
plain := []byte("rounds-test")
for _, r := range []int{16, 32, 64, 128, 256, 512, 1024} {
enc, err := EncryptAESSimple(pw, plain, r)
tst.AssertNoErr(t, err)
dec, err := DecryptAESSimple(pw, enc)
tst.AssertNoErr(t, err)
tst.AssertEqual(t, string(dec), string(plain))
}
}
func TestAESSimpleResultIsBase32(t *testing.T) {
pw := []byte("hunter12")
plain := []byte("Hello World")
enc, err := EncryptAESSimple(pw, plain, 64)
tst.AssertNoErr(t, err)
for _, c := range enc {
isUpper := c >= 'A' && c <= 'Z'
isDigit := c >= '2' && c <= '7'
if !(isUpper || isDigit) {
t.Errorf("non-base32 character %q in output", c)
break
}
}
}
+1 -1
View File
@@ -2,7 +2,7 @@ package cryptext
import (
"fmt"
"gogs.mikescher.com/BlackForestBytes/goext/tst"
"git.blackforestbytes.com/BlackForestBytes/goext/tst"
"testing"
)
+54
View File
@@ -0,0 +1,54 @@
package cryptext
import (
"git.blackforestbytes.com/BlackForestBytes/goext/tst"
"strings"
"testing"
)
func TestStrSha256SameAsBytesSha256(t *testing.T) {
inputs := []string{"", "a", "Hello World", "lorem ipsum dolor sit amet", "🎉 unicode"}
for _, in := range inputs {
tst.AssertEqual(t, StrSha256(in), BytesSha256([]byte(in)))
}
}
func TestStrSha256Length(t *testing.T) {
// SHA-256 hex output must always be 64 characters
tst.AssertEqual(t, len(StrSha256("")), 64)
tst.AssertEqual(t, len(StrSha256("x")), 64)
tst.AssertEqual(t, len(StrSha256(strings.Repeat("x", 10000))), 64)
}
func TestStrSha256Deterministic(t *testing.T) {
v := "deterministic input"
a := StrSha256(v)
b := StrSha256(v)
tst.AssertEqual(t, a, b)
}
func TestStrSha256DifferentInputs(t *testing.T) {
tst.AssertNotEqual(t, StrSha256("a"), StrSha256("b"))
tst.AssertNotEqual(t, StrSha256("Hello"), StrSha256("hello"))
tst.AssertNotEqual(t, StrSha256("Hello World"), StrSha256("Hello World "))
}
func TestStrSha256IsHex(t *testing.T) {
out := StrSha256("anything")
for _, c := range out {
isLowerHex := (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')
if !isLowerHex {
t.Errorf("non-hex char %q in StrSha256 output", c)
return
}
}
}
func TestBytesSha256NilSameAsEmpty(t *testing.T) {
tst.AssertEqual(t, BytesSha256(nil), BytesSha256([]byte{}))
}
func TestBytesSha256KnownVectors(t *testing.T) {
// "abc" => sha-256 standard vector
tst.AssertEqual(t, StrSha256("abc"), "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad")
}
+1 -1
View File
@@ -1,7 +1,7 @@
package cryptext
import (
"gogs.mikescher.com/BlackForestBytes/goext/tst"
"git.blackforestbytes.com/BlackForestBytes/goext/tst"
"testing"
)
+84 -12
View File
@@ -3,25 +3,29 @@ package cryptext
import (
"crypto/rand"
"crypto/sha256"
"crypto/sha512"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"gogs.mikescher.com/BlackForestBytes/goext/langext"
"gogs.mikescher.com/BlackForestBytes/goext/totpext"
"golang.org/x/crypto/bcrypt"
"strconv"
"strings"
"git.blackforestbytes.com/BlackForestBytes/goext/langext"
"git.blackforestbytes.com/BlackForestBytes/goext/totpext"
"golang.org/x/crypto/bcrypt"
)
const LatestPassHashVersion = 4
const LatestPassHashVersion = 5
// PassHash
// - [v0]: plaintext password ( `0|...` )
// - [v1]: sha256(plaintext)
// - [v2]: seed | sha256<seed>(plaintext)
// - [v3]: seed | sha256<seed>(plaintext) | [hex(totp)]
// - [v4]: bcrypt(plaintext) | [hex(totp)]
// - [v0]: plaintext password ( `0|...` ) // simple, used to write PW's directly in DB
// - [v1]: sha256(plaintext) // simple hashing
// - [v2]: seed | sha256<seed>(plaintext) // add seed
// - [v3]: seed | sha256<seed>(plaintext) | [hex(totp)] // add TOTP support
// - [v4]: bcrypt(plaintext) | [hex(totp)] // use proper bcrypt
// - [v5]: bcrypt(sha512(plaintext)) | [hex(totp)] // hash pw before bcrypt (otherwise max pw-len = 72)
type PassHash string
func (ph PassHash) Valid() bool {
@@ -64,7 +68,6 @@ func (ph PassHash) Data() (_version int, _seed []byte, _payload []byte, _totp bo
return int(version), nil, payload, false, nil, true
}
//
if version == 2 {
if len(split) != 3 {
return -1, nil, nil, false, nil, false
@@ -109,7 +112,21 @@ func (ph PassHash) Data() (_version int, _seed []byte, _payload []byte, _totp bo
totp := false
totpsecret := make([]byte, 0)
if split[2] != "0" {
totpsecret, err = hex.DecodeString(split[3])
totpsecret, err = hex.DecodeString(split[2])
totp = true
}
return int(version), nil, payload, totp, totpsecret, true
}
if version == 5 {
if len(split) != 3 {
return -1, nil, nil, false, nil, false
}
payload := []byte(split[1])
totp := false
totpsecret := make([]byte, 0)
if split[2] != "0" {
totpsecret, err = hex.DecodeString(split[2])
totp = true
}
return int(version), nil, payload, totp, totpsecret, true
@@ -156,6 +173,14 @@ func (ph PassHash) Verify(plainpass string, totp *string) bool {
}
}
if version == 5 {
if !hastotp {
return bcrypt.CompareHashAndPassword(payload, hash512(plainpass)) == nil
} else {
return bcrypt.CompareHashAndPassword(payload, hash512(plainpass)) == nil && totpext.Validate(totpsecret, *totp)
}
}
return false
}
@@ -209,6 +234,12 @@ func (ph PassHash) ClearTOTP() (PassHash, error) {
return PassHash(strings.Join(split, "|")), nil
}
if version == 5 {
split := strings.Split(string(ph), "|")
split[2] = "0"
return PassHash(strings.Join(split, "|")), nil
}
return "", errors.New("unknown version")
}
@@ -242,6 +273,12 @@ func (ph PassHash) WithTOTP(totpSecret []byte) (PassHash, error) {
return PassHash(strings.Join(split, "|")), nil
}
if version == 5 {
split := strings.Split(string(ph), "|")
split[2] = hex.EncodeToString(totpSecret)
return PassHash(strings.Join(split, "|")), nil
}
return "", errors.New("unknown version")
}
@@ -271,6 +308,10 @@ func (ph PassHash) Change(newPlainPass string) (PassHash, error) {
return HashPasswordV4(newPlainPass, langext.Conditional(hastotp, totpsecret, nil))
}
if version == 5 {
return HashPasswordV5(newPlainPass, langext.Conditional(hastotp, totpsecret, nil))
}
return "", errors.New("unknown version")
}
@@ -278,8 +319,32 @@ func (ph PassHash) String() string {
return string(ph)
}
func (ph PassHash) MarshalJSON() ([]byte, error) {
if ph == "" {
return json.Marshal("")
}
return json.Marshal("*****")
}
func HashPassword(plainpass string, totpSecret []byte) (PassHash, error) {
return HashPasswordV4(plainpass, totpSecret)
return HashPasswordV5(plainpass, totpSecret)
}
func HashPasswordV5(plainpass string, totpSecret []byte) (PassHash, error) {
var strtotp string
if totpSecret == nil {
strtotp = "0"
} else {
strtotp = hex.EncodeToString(totpSecret)
}
payload, err := bcrypt.GenerateFromPassword(hash512(plainpass), bcrypt.MinCost)
if err != nil {
return "", err
}
return PassHash(fmt.Sprintf("5|%s|%s", string(payload), strtotp)), nil
}
func HashPasswordV4(plainpass string, totpSecret []byte) (PassHash, error) {
@@ -340,6 +405,13 @@ func HashPasswordV0(plainpass string) (PassHash, error) {
return PassHash(fmt.Sprintf("0|%s", plainpass)), nil
}
func hash512(s string) []byte {
h := sha512.New()
h.Write([]byte(s))
bs := h.Sum(nil)
return bs
}
func hash256(s string) []byte {
h := sha256.New()
h.Write([]byte(s))
+379
View File
@@ -0,0 +1,379 @@
package cryptext
import (
"encoding/json"
"git.blackforestbytes.com/BlackForestBytes/goext/tst"
"strings"
"testing"
)
func TestPassHashInvalidEmpty(t *testing.T) {
ph := PassHash("")
tst.AssertFalse(t, ph.Valid())
tst.AssertFalse(t, ph.HasTOTP())
tst.AssertFalse(t, ph.NeedsPasswordUpgrade())
}
func TestPassHashInvalidGarbage(t *testing.T) {
for _, raw := range []string{
"garbage",
"99|nope",
"abc|payload",
"3|onlytwo",
"4|onlytwo",
"5|onlytwo",
"2|notbase64!|notbase64!",
"1|!!!notbase64!!!",
"3|!!notb64|!!notb64|0",
"3|abc|!!notb64|0",
} {
ph := PassHash(raw)
if ph.Valid() {
t.Errorf("expected %q to be invalid", raw)
}
}
}
func TestPassHashVerifyInvalid(t *testing.T) {
ph := PassHash("garbage-value")
tst.AssertFalse(t, ph.Verify("anything", nil))
}
func TestPassHashUpgradeInvalid(t *testing.T) {
ph := PassHash("garbage-value")
_, err := ph.Upgrade("anything")
if err == nil {
t.Errorf("expected error for invalid PassHash upgrade")
}
}
func TestPassHashStringRoundtrip(t *testing.T) {
ph, err := HashPassword("hunter2", nil)
tst.AssertNoErr(t, err)
tst.AssertEqual(t, ph.String(), string(ph))
}
func TestPassHashMarshalJSONEmpty(t *testing.T) {
ph := PassHash("")
data, err := json.Marshal(ph)
tst.AssertNoErr(t, err)
tst.AssertEqual(t, string(data), `""`)
}
func TestPassHashMarshalJSONMasked(t *testing.T) {
ph, err := HashPassword("hunter2", nil)
tst.AssertNoErr(t, err)
data, err := json.Marshal(ph)
tst.AssertNoErr(t, err)
tst.AssertEqual(t, string(data), `"*****"`)
}
func TestPassHashDataV0(t *testing.T) {
ph, err := HashPasswordV0("test123")
tst.AssertNoErr(t, err)
v, seed, payload, hastotp, totpsecret, valid := ph.Data()
tst.AssertTrue(t, valid)
tst.AssertEqual(t, v, 0)
tst.AssertEqual(t, len(seed), 0)
tst.AssertEqual(t, string(payload), "test123")
tst.AssertFalse(t, hastotp)
tst.AssertEqual(t, len(totpsecret), 0)
}
func TestPassHashDataV1(t *testing.T) {
ph, err := HashPasswordV1("test123")
tst.AssertNoErr(t, err)
v, seed, payload, hastotp, _, valid := ph.Data()
tst.AssertTrue(t, valid)
tst.AssertEqual(t, v, 1)
tst.AssertEqual(t, len(seed), 0)
tst.AssertEqual(t, len(payload), 32) // sha-256 is 32 bytes
tst.AssertFalse(t, hastotp)
}
func TestPassHashDataV2(t *testing.T) {
ph, err := HashPasswordV2("test123")
tst.AssertNoErr(t, err)
v, seed, payload, hastotp, _, valid := ph.Data()
tst.AssertTrue(t, valid)
tst.AssertEqual(t, v, 2)
tst.AssertEqual(t, len(seed), 32)
tst.AssertEqual(t, len(payload), 32)
tst.AssertFalse(t, hastotp)
}
func TestPassHashDataV3(t *testing.T) {
ph, err := HashPasswordV3("test123", nil)
tst.AssertNoErr(t, err)
v, seed, payload, hastotp, _, valid := ph.Data()
tst.AssertTrue(t, valid)
tst.AssertEqual(t, v, 3)
tst.AssertEqual(t, len(seed), 32)
tst.AssertEqual(t, len(payload), 32)
tst.AssertFalse(t, hastotp)
}
func TestPassHashDataV4(t *testing.T) {
ph, err := HashPasswordV4("test123", nil)
tst.AssertNoErr(t, err)
v, _, _, hastotp, _, valid := ph.Data()
tst.AssertTrue(t, valid)
tst.AssertEqual(t, v, 4)
tst.AssertFalse(t, hastotp)
}
func TestPassHashDataV5(t *testing.T) {
ph, err := HashPasswordV5("test123", nil)
tst.AssertNoErr(t, err)
v, _, _, hastotp, _, valid := ph.Data()
tst.AssertTrue(t, valid)
tst.AssertEqual(t, v, 5)
tst.AssertFalse(t, hastotp)
}
func TestPassHashLatestIsV5(t *testing.T) {
ph, err := HashPassword("test", nil)
tst.AssertNoErr(t, err)
v, _, _, _, _, valid := ph.Data()
tst.AssertTrue(t, valid)
tst.AssertEqual(t, v, LatestPassHashVersion)
tst.AssertEqual(t, v, 5)
}
func TestPassHashUpgradeLatestIsNoop(t *testing.T) {
ph, err := HashPassword("test", nil)
tst.AssertNoErr(t, err)
tst.AssertFalse(t, ph.NeedsPasswordUpgrade())
ph2, err := ph.Upgrade("test")
tst.AssertNoErr(t, err)
tst.AssertEqual(t, string(ph), string(ph2))
}
func TestPassHashClearTOTPInvalid(t *testing.T) {
_, err := PassHash("garbage").ClearTOTP()
if err == nil {
t.Errorf("expected error from ClearTOTP on invalid")
}
}
func TestPassHashClearTOTPV0V1V2Noop(t *testing.T) {
ph0, _ := HashPasswordV0("x")
r0, err := ph0.ClearTOTP()
tst.AssertNoErr(t, err)
tst.AssertEqual(t, string(r0), string(ph0))
ph1, _ := HashPasswordV1("x")
r1, err := ph1.ClearTOTP()
tst.AssertNoErr(t, err)
tst.AssertEqual(t, string(r1), string(ph1))
ph2, _ := HashPasswordV2("x")
r2, err := ph2.ClearTOTP()
tst.AssertNoErr(t, err)
tst.AssertEqual(t, string(r2), string(ph2))
}
func TestPassHashClearTOTPV3(t *testing.T) {
secret := []byte{0x01, 0x02, 0x03}
ph, err := HashPasswordV3("test123", secret)
tst.AssertNoErr(t, err)
tst.AssertTrue(t, ph.HasTOTP())
cleared, err := ph.ClearTOTP()
tst.AssertNoErr(t, err)
tst.AssertFalse(t, cleared.HasTOTP())
tst.AssertTrue(t, cleared.Valid())
tst.AssertTrue(t, cleared.Verify("test123", nil))
}
func TestPassHashClearTOTPV4(t *testing.T) {
secret := []byte{0x01, 0x02, 0x03}
ph, err := HashPasswordV4("test123", secret)
tst.AssertNoErr(t, err)
tst.AssertTrue(t, ph.HasTOTP())
cleared, err := ph.ClearTOTP()
tst.AssertNoErr(t, err)
tst.AssertFalse(t, cleared.HasTOTP())
tst.AssertTrue(t, cleared.Verify("test123", nil))
}
func TestPassHashClearTOTPV5(t *testing.T) {
secret := []byte{0x01, 0x02, 0x03}
ph, err := HashPasswordV5("test123", secret)
tst.AssertNoErr(t, err)
tst.AssertTrue(t, ph.HasTOTP())
cleared, err := ph.ClearTOTP()
tst.AssertNoErr(t, err)
tst.AssertFalse(t, cleared.HasTOTP())
tst.AssertTrue(t, cleared.Verify("test123", nil))
}
func TestPassHashWithTOTPInvalid(t *testing.T) {
_, err := PassHash("garbage").WithTOTP([]byte{0x01})
if err == nil {
t.Errorf("expected error for WithTOTP on invalid")
}
}
func TestPassHashWithTOTPV0V1V2Errors(t *testing.T) {
ph0, _ := HashPasswordV0("x")
if _, err := ph0.WithTOTP([]byte{0x01}); err == nil {
t.Errorf("expected v0 not to support TOTP")
}
ph1, _ := HashPasswordV1("x")
if _, err := ph1.WithTOTP([]byte{0x01}); err == nil {
t.Errorf("expected v1 not to support TOTP")
}
ph2, _ := HashPasswordV2("x")
if _, err := ph2.WithTOTP([]byte{0x01}); err == nil {
t.Errorf("expected v2 not to support TOTP")
}
}
func TestPassHashWithTOTPV3V4V5(t *testing.T) {
secret := []byte{0xDE, 0xAD, 0xBE, 0xEF}
ph3, _ := HashPasswordV3("pw", nil)
tst.AssertFalse(t, ph3.HasTOTP())
r3, err := ph3.WithTOTP(secret)
tst.AssertNoErr(t, err)
tst.AssertTrue(t, r3.HasTOTP())
ph4, _ := HashPasswordV4("pw", nil)
tst.AssertFalse(t, ph4.HasTOTP())
r4, err := ph4.WithTOTP(secret)
tst.AssertNoErr(t, err)
tst.AssertTrue(t, r4.HasTOTP())
ph5, _ := HashPasswordV5("pw", nil)
tst.AssertFalse(t, ph5.HasTOTP())
r5, err := ph5.WithTOTP(secret)
tst.AssertNoErr(t, err)
tst.AssertTrue(t, r5.HasTOTP())
}
func TestPassHashChangeInvalid(t *testing.T) {
_, err := PassHash("garbage").Change("new-pw")
if err == nil {
t.Errorf("expected error from Change on invalid")
}
}
func TestPassHashChangeKeepsVersion(t *testing.T) {
cases := []struct {
name string
hashed func() (PassHash, error)
version int
}{
{"V0", func() (PassHash, error) { return HashPasswordV0("old") }, 0},
{"V1", func() (PassHash, error) { return HashPasswordV1("old") }, 1},
{"V2", func() (PassHash, error) { return HashPasswordV2("old") }, 2},
{"V3", func() (PassHash, error) { return HashPasswordV3("old", nil) }, 3},
{"V4", func() (PassHash, error) { return HashPasswordV4("old", nil) }, 4},
{"V5", func() (PassHash, error) { return HashPasswordV5("old", nil) }, 5},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
ph, err := c.hashed()
tst.AssertNoErr(t, err)
changed, err := ph.Change("new-pw")
tst.AssertNoErr(t, err)
v, _, _, _, _, valid := changed.Data()
tst.AssertTrue(t, valid)
tst.AssertEqual(t, v, c.version)
tst.AssertTrue(t, changed.Verify("new-pw", nil))
tst.AssertFalse(t, changed.Verify("old", nil))
})
}
}
func TestPassHashChangeKeepsTOTPV3(t *testing.T) {
secret := []byte{0xAB, 0xCD}
ph, err := HashPasswordV3("old", secret)
tst.AssertNoErr(t, err)
tst.AssertTrue(t, ph.HasTOTP())
changed, err := ph.Change("new")
tst.AssertNoErr(t, err)
tst.AssertTrue(t, changed.HasTOTP())
}
func TestPassHashV0Format(t *testing.T) {
ph, err := HashPasswordV0("plaintext-pw")
tst.AssertNoErr(t, err)
tst.AssertTrue(t, strings.HasPrefix(string(ph), "0|"))
tst.AssertEqual(t, string(ph), "0|plaintext-pw")
}
func TestPassHashV1Format(t *testing.T) {
ph, err := HashPasswordV1("test")
tst.AssertNoErr(t, err)
tst.AssertTrue(t, strings.HasPrefix(string(ph), "1|"))
}
func TestPassHashV2Format(t *testing.T) {
ph, err := HashPasswordV2("test")
tst.AssertNoErr(t, err)
tst.AssertTrue(t, strings.HasPrefix(string(ph), "2|"))
tst.AssertEqual(t, strings.Count(string(ph), "|"), 2)
}
func TestPassHashV3Format(t *testing.T) {
ph, err := HashPasswordV3("test", nil)
tst.AssertNoErr(t, err)
tst.AssertTrue(t, strings.HasPrefix(string(ph), "3|"))
tst.AssertEqual(t, strings.Count(string(ph), "|"), 3)
tst.AssertTrue(t, strings.HasSuffix(string(ph), "|0"))
}
func TestPassHashV4Format(t *testing.T) {
ph, err := HashPasswordV4("test", nil)
tst.AssertNoErr(t, err)
tst.AssertTrue(t, strings.HasPrefix(string(ph), "4|"))
tst.AssertTrue(t, strings.HasSuffix(string(ph), "|0"))
}
func TestPassHashV5Format(t *testing.T) {
ph, err := HashPasswordV5("test", nil)
tst.AssertNoErr(t, err)
tst.AssertTrue(t, strings.HasPrefix(string(ph), "5|"))
tst.AssertTrue(t, strings.HasSuffix(string(ph), "|0"))
}
func TestPassHashV5VerifyLongPassword(t *testing.T) {
// V5 hashes via sha512 first → bcrypt's 72-byte limit shouldn't apply
longPw := strings.Repeat("a", 200)
ph, err := HashPasswordV5(longPw, nil)
tst.AssertNoErr(t, err)
tst.AssertTrue(t, ph.Verify(longPw, nil))
tst.AssertFalse(t, ph.Verify(longPw+"x", nil))
}
func TestPassHashV5DifferentEachCall(t *testing.T) {
ph1, err := HashPasswordV5("samepw", nil)
tst.AssertNoErr(t, err)
ph2, err := HashPasswordV5("samepw", nil)
tst.AssertNoErr(t, err)
// Bcrypt salts internally — same password should produce different hashes
tst.AssertNotEqual(t, string(ph1), string(ph2))
// Both must verify
tst.AssertTrue(t, ph1.Verify("samepw", nil))
tst.AssertTrue(t, ph2.Verify("samepw", nil))
}
+209
View File
@@ -0,0 +1,209 @@
package cryptext
import (
"git.blackforestbytes.com/BlackForestBytes/goext/totpext"
"git.blackforestbytes.com/BlackForestBytes/goext/tst"
"testing"
)
func TestPassHash1(t *testing.T) {
ph, err := HashPassword("test123", nil)
tst.AssertNoErr(t, err)
tst.AssertTrue(t, ph.Valid())
tst.AssertFalse(t, ph.HasTOTP())
tst.AssertFalse(t, ph.NeedsPasswordUpgrade())
tst.AssertTrue(t, ph.Verify("test123", nil))
tst.AssertFalse(t, ph.Verify("test124", nil))
}
func TestPassHashTOTP(t *testing.T) {
sec, err := totpext.GenerateSecret()
tst.AssertNoErr(t, err)
ph, err := HashPassword("test123", sec)
tst.AssertNoErr(t, err)
tst.AssertTrue(t, ph.Valid())
tst.AssertTrue(t, ph.HasTOTP())
tst.AssertFalse(t, ph.NeedsPasswordUpgrade())
tst.AssertFalse(t, ph.Verify("test123", nil))
tst.AssertFalse(t, ph.Verify("test124", nil))
tst.AssertTrue(t, ph.Verify("test123", new(totpext.TOTP(sec))))
tst.AssertFalse(t, ph.Verify("test124", nil))
}
func TestPassHashUpgrade_V0(t *testing.T) {
ph, err := HashPasswordV0("test123")
tst.AssertNoErr(t, err)
tst.AssertTrue(t, ph.Valid())
tst.AssertFalse(t, ph.HasTOTP())
tst.AssertTrue(t, ph.NeedsPasswordUpgrade())
tst.AssertTrue(t, ph.Verify("test123", nil))
tst.AssertFalse(t, ph.Verify("test124", nil))
ph, err = ph.Upgrade("test123")
tst.AssertNoErr(t, err)
tst.AssertTrue(t, ph.Valid())
tst.AssertFalse(t, ph.HasTOTP())
tst.AssertFalse(t, ph.NeedsPasswordUpgrade())
tst.AssertTrue(t, ph.Verify("test123", nil))
tst.AssertFalse(t, ph.Verify("test124", nil))
}
func TestPassHashUpgrade_V1(t *testing.T) {
ph, err := HashPasswordV1("test123")
tst.AssertNoErr(t, err)
tst.AssertTrue(t, ph.Valid())
tst.AssertFalse(t, ph.HasTOTP())
tst.AssertTrue(t, ph.NeedsPasswordUpgrade())
tst.AssertTrue(t, ph.Verify("test123", nil))
tst.AssertFalse(t, ph.Verify("test124", nil))
ph, err = ph.Upgrade("test123")
tst.AssertNoErr(t, err)
tst.AssertTrue(t, ph.Valid())
tst.AssertFalse(t, ph.HasTOTP())
tst.AssertFalse(t, ph.NeedsPasswordUpgrade())
tst.AssertTrue(t, ph.Verify("test123", nil))
tst.AssertFalse(t, ph.Verify("test124", nil))
}
func TestPassHashUpgrade_V2(t *testing.T) {
ph, err := HashPasswordV2("test123")
tst.AssertNoErr(t, err)
tst.AssertTrue(t, ph.Valid())
tst.AssertFalse(t, ph.HasTOTP())
tst.AssertTrue(t, ph.NeedsPasswordUpgrade())
tst.AssertTrue(t, ph.Verify("test123", nil))
tst.AssertFalse(t, ph.Verify("test124", nil))
ph, err = ph.Upgrade("test123")
tst.AssertNoErr(t, err)
tst.AssertTrue(t, ph.Valid())
tst.AssertFalse(t, ph.HasTOTP())
tst.AssertFalse(t, ph.NeedsPasswordUpgrade())
tst.AssertTrue(t, ph.Verify("test123", nil))
tst.AssertFalse(t, ph.Verify("test124", nil))
}
func TestPassHashUpgrade_V3(t *testing.T) {
ph, err := HashPasswordV3("test123", nil)
tst.AssertNoErr(t, err)
tst.AssertTrue(t, ph.Valid())
tst.AssertFalse(t, ph.HasTOTP())
tst.AssertTrue(t, ph.NeedsPasswordUpgrade())
tst.AssertTrue(t, ph.Verify("test123", nil))
tst.AssertFalse(t, ph.Verify("test124", nil))
ph, err = ph.Upgrade("test123")
tst.AssertNoErr(t, err)
tst.AssertTrue(t, ph.Valid())
tst.AssertFalse(t, ph.HasTOTP())
tst.AssertFalse(t, ph.NeedsPasswordUpgrade())
tst.AssertTrue(t, ph.Verify("test123", nil))
tst.AssertFalse(t, ph.Verify("test124", nil))
}
func TestPassHashUpgrade_V3_TOTP(t *testing.T) {
sec, err := totpext.GenerateSecret()
tst.AssertNoErr(t, err)
ph, err := HashPasswordV3("test123", sec)
tst.AssertNoErr(t, err)
tst.AssertTrue(t, ph.Valid())
tst.AssertTrue(t, ph.HasTOTP())
tst.AssertTrue(t, ph.NeedsPasswordUpgrade())
tst.AssertFalse(t, ph.Verify("test123", nil))
tst.AssertFalse(t, ph.Verify("test124", nil))
tst.AssertTrue(t, ph.Verify("test123", new(totpext.TOTP(sec))))
tst.AssertFalse(t, ph.Verify("test124", nil))
ph, err = ph.Upgrade("test123")
tst.AssertNoErr(t, err)
tst.AssertTrue(t, ph.Valid())
tst.AssertTrue(t, ph.HasTOTP())
tst.AssertFalse(t, ph.NeedsPasswordUpgrade())
tst.AssertFalse(t, ph.Verify("test123", nil))
tst.AssertFalse(t, ph.Verify("test124", nil))
tst.AssertTrue(t, ph.Verify("test123", new(totpext.TOTP(sec))))
tst.AssertFalse(t, ph.Verify("test124", nil))
}
func TestPassHashUpgrade_V4(t *testing.T) {
ph, err := HashPasswordV4("test123", nil)
tst.AssertNoErr(t, err)
tst.AssertTrue(t, ph.Valid())
tst.AssertFalse(t, ph.HasTOTP())
tst.AssertTrue(t, ph.NeedsPasswordUpgrade())
tst.AssertTrue(t, ph.Verify("test123", nil))
tst.AssertFalse(t, ph.Verify("test124", nil))
ph, err = ph.Upgrade("test123")
tst.AssertNoErr(t, err)
tst.AssertTrue(t, ph.Valid())
tst.AssertFalse(t, ph.HasTOTP())
tst.AssertFalse(t, ph.NeedsPasswordUpgrade())
tst.AssertTrue(t, ph.Verify("test123", nil))
tst.AssertFalse(t, ph.Verify("test124", nil))
}
func TestPassHashUpgrade_V4_TOTP(t *testing.T) {
sec, err := totpext.GenerateSecret()
tst.AssertNoErr(t, err)
ph, err := HashPasswordV4("test123", sec)
tst.AssertNoErr(t, err)
tst.AssertTrue(t, ph.Valid())
tst.AssertTrue(t, ph.HasTOTP())
tst.AssertTrue(t, ph.NeedsPasswordUpgrade())
tst.AssertFalse(t, ph.Verify("test123", nil))
tst.AssertFalse(t, ph.Verify("test124", nil))
tst.AssertTrue(t, ph.Verify("test123", new(totpext.TOTP(sec))))
tst.AssertFalse(t, ph.Verify("test124", nil))
ph, err = ph.Upgrade("test123")
tst.AssertNoErr(t, err)
tst.AssertTrue(t, ph.Valid())
tst.AssertTrue(t, ph.HasTOTP())
tst.AssertFalse(t, ph.NeedsPasswordUpgrade())
tst.AssertFalse(t, ph.Verify("test123", nil))
tst.AssertFalse(t, ph.Verify("test124", nil))
tst.AssertTrue(t, ph.Verify("test123", new(totpext.TOTP(sec))))
tst.AssertFalse(t, ph.Verify("test124", nil))
}
+263
View File
@@ -0,0 +1,263 @@
package cryptext
import (
"crypto/rand"
"io"
"math/big"
mathrand "math/rand"
"strings"
)
const (
ppStartChar = "BCDFGHJKLMNPQRSTVWXZ"
ppEndChar = "ABDEFIKMNORSTUXYZ"
ppVowel = "AEIOUY"
ppConsonant = "BCDFGHJKLMNPQRSTVWXZ"
ppSegmentLenMin = 3
ppSegmentLenMax = 7
ppMaxRepeatedVowel = 2
ppMaxRepeatedConsonant = 2
)
var ppContinuation = map[uint8]string{
'A': "BCDFGHJKLMNPRSTVWXYZ",
'B': "ADFIKLMNORSTUY",
'C': "AEIKOUY",
'D': "AEILORSUYZ",
'E': "BCDFGHJKLMNPRSTVWXYZ",
'F': "ADEGIKLOPRTUY",
'G': "ABDEFHILMNORSTUY",
'H': "AEIOUY",
'I': "BCDFGHJKLMNPRSTVWXZ",
'J': "AEIOUY",
'K': "ADEFHILMNORSTUY",
'L': "ADEFGIJKMNOPSTUVWYZ",
'M': "ABEFIKOPSTUY",
'N': "ABEFIKOPSTUY",
'O': "BCDFGHJKLMNPRSTVWXYZ",
'P': "AEFIJLORSTUY",
'Q': "AEIOUY",
'R': "ADEFGHIJKLMNOPSTUVYZ",
'S': "ACDEIKLOPTUYZ",
'T': "AEHIJOPRSUWY",
'U': "BCDFGHJKLMNPRSTVWXZ",
'V': "AEIOUY",
'W': "AEIOUY",
'X': "AEIOUY",
'Y': "ABCDFGHKLMNPRSTVXZ",
'Z': "AEILOTUY",
}
var ppLog2Map = map[int]float64{
1: 0.00000000,
2: 1.00000000,
3: 1.58496250,
4: 2.00000000,
5: 2.32192809,
6: 2.58496250,
7: 2.80735492,
8: 3.00000000,
9: 3.16992500,
10: 3.32192809,
11: 3.45943162,
12: 3.58496250,
13: 3.70043972,
14: 3.80735492,
15: 3.90689060,
16: 4.00000000,
17: 4.08746284,
18: 4.16992500,
19: 4.24792751,
20: 4.32192809,
21: 4.39231742,
22: 4.45943162,
23: 4.52356196,
24: 4.58496250,
25: 4.64385619,
26: 4.70043972,
27: 4.75488750,
28: 4.80735492,
29: 4.85798100,
30: 4.90689060,
31: 4.95419631,
32: 5.00000000,
}
var (
ppVowelMap = ppMakeSet(ppVowel)
ppConsonantMap = ppMakeSet(ppConsonant)
ppEndCharMap = ppMakeSet(ppEndChar)
)
func ppMakeSet(v string) map[uint8]bool {
mp := make(map[uint8]bool, len(v))
for _, chr := range v {
mp[uint8(chr)] = true
}
return mp
}
func ppRandInt(rng io.Reader, max int) int {
v, err := rand.Int(rng, big.NewInt(int64(max)))
if err != nil {
panic(err)
}
return int(v.Int64())
}
func ppRand(rng io.Reader, chars string, entropy *float64) uint8 {
chr := chars[ppRandInt(rng, len(chars))]
*entropy = *entropy + ppLog2Map[len(chars)]
return chr
}
func ppCharType(chr uint8) (bool, bool) {
_, ok1 := ppVowelMap[chr]
_, ok2 := ppConsonantMap[chr]
return ok1, ok2
}
func ppCharsetRemove(cs string, set map[uint8]bool, allowEmpty bool) string {
result := ""
for _, chr := range cs {
if _, ok := set[uint8(chr)]; !ok {
result += string(chr)
}
}
if result == "" && !allowEmpty {
return cs
}
return result
}
func ppCharsetFilter(cs string, set map[uint8]bool, allowEmpty bool) string {
result := ""
for _, chr := range cs {
if _, ok := set[uint8(chr)]; ok {
result += string(chr)
}
}
if result == "" && !allowEmpty {
return cs
}
return result
}
func PronouncablePasswordExt(rng io.Reader, pwlen int) (string, float64) {
// kinda pseudo markov-chain - with a few extra rules and no weights...
if pwlen <= 0 {
return "", 0
}
vowelCount := 0
consoCount := 0
entropy := float64(0)
startChar := ppRand(rng, ppStartChar, &entropy)
result := string(startChar)
currentChar := startChar
isVowel, isConsonant := ppCharType(currentChar)
if isVowel {
vowelCount = 1
}
if isConsonant {
consoCount = ppMaxRepeatedConsonant
}
segmentLen := 1
segmentLenTarget := ppSegmentLenMin + ppRandInt(rng, ppSegmentLenMax-ppSegmentLenMin)
for len(result) < pwlen {
charset := ppContinuation[currentChar]
if vowelCount >= ppMaxRepeatedVowel {
charset = ppCharsetRemove(charset, ppVowelMap, false)
}
if consoCount >= ppMaxRepeatedConsonant {
charset = ppCharsetRemove(charset, ppConsonantMap, false)
}
lastOfSegment := false
newSegment := false
if len(result)+1 == pwlen {
// last of result
charset = ppCharsetFilter(charset, ppEndCharMap, false)
} else if segmentLen+1 == segmentLenTarget {
// last of segment
charsetNew := ppCharsetFilter(charset, ppEndCharMap, true)
if charsetNew != "" {
charset = charsetNew
lastOfSegment = true
}
} else if segmentLen >= segmentLenTarget {
// (perhaps) start of new segment
if _, ok := ppEndCharMap[currentChar]; ok {
charset = ppStartChar
newSegment = true
} else {
// continue segment for one more char to (hopefully) find an end-char
charsetNew := ppCharsetFilter(charset, ppEndCharMap, true)
if charsetNew != "" {
charset = charsetNew
lastOfSegment = true
}
}
} else {
// normal continuation
}
newChar := ppRand(rng, charset, &entropy)
if lastOfSegment {
currentChar = newChar
segmentLen++
result += strings.ToLower(string(newChar))
} else if newSegment {
currentChar = newChar
segmentLen = 1
result += strings.ToUpper(string(newChar))
segmentLenTarget = ppSegmentLenMin + ppRandInt(rng, ppSegmentLenMax-ppSegmentLenMin)
vowelCount = 0
consoCount = 0
} else {
currentChar = newChar
segmentLen++
result += strings.ToLower(string(newChar))
}
isVowel, isConsonant := ppCharType(currentChar)
if isVowel {
vowelCount++
consoCount = 0
}
if isConsonant {
vowelCount = 0
if newSegment {
consoCount = ppMaxRepeatedConsonant
} else {
consoCount++
}
}
}
return result, entropy
}
func PronouncablePassword(len int) string {
v, _ := PronouncablePasswordExt(rand.Reader, len)
return v
}
func PronouncablePasswordSeeded(seed int64, len int) string {
v, _ := PronouncablePasswordExt(mathrand.New(mathrand.NewSource(seed)), len)
return v
}
@@ -0,0 +1,180 @@
package cryptext
import (
"git.blackforestbytes.com/BlackForestBytes/goext/tst"
mathrand "math/rand"
"strings"
"testing"
"unicode"
)
func TestPronouncablePasswordLength(t *testing.T) {
for _, n := range []int{1, 2, 3, 5, 8, 13, 21, 50, 128} {
pw := PronouncablePassword(n)
tst.AssertEqual(t, len(pw), n)
}
}
func TestPronouncablePasswordZeroOrNegative(t *testing.T) {
tst.AssertEqual(t, PronouncablePassword(0), "")
tst.AssertEqual(t, PronouncablePassword(-1), "")
tst.AssertEqual(t, PronouncablePassword(-1000), "")
}
func TestPronouncablePasswordSeededDeterministic(t *testing.T) {
pw1 := PronouncablePasswordSeeded(42, 16)
pw2 := PronouncablePasswordSeeded(42, 16)
tst.AssertEqual(t, pw1, pw2)
tst.AssertEqual(t, len(pw1), 16)
}
func TestPronouncablePasswordSeededDifferentSeeds(t *testing.T) {
pw1 := PronouncablePasswordSeeded(1, 16)
pw2 := PronouncablePasswordSeeded(2, 16)
tst.AssertNotEqual(t, pw1, pw2)
}
func TestPronouncablePasswordExtEntropy(t *testing.T) {
rng := mathrand.New(mathrand.NewSource(1))
pw, entropy := PronouncablePasswordExt(rng, 32)
tst.AssertEqual(t, len(pw), 32)
if entropy <= 0 {
t.Errorf("expected positive entropy, got %f", entropy)
}
}
func TestPronouncablePasswordExtZeroLen(t *testing.T) {
rng := mathrand.New(mathrand.NewSource(1))
pw, entropy := PronouncablePasswordExt(rng, 0)
tst.AssertEqual(t, pw, "")
tst.AssertEqual(t, entropy, float64(0))
}
func TestPronouncablePasswordCharacters(t *testing.T) {
// Output should be only ASCII letters
for i := range 50 {
pw := PronouncablePasswordSeeded(int64(i), 32)
for _, c := range pw {
if !unicode.IsLetter(c) || c > unicode.MaxASCII {
t.Errorf("non-letter or non-ASCII rune %q in password %q", c, pw)
break
}
}
}
}
func TestPronouncablePasswordStartsUpper(t *testing.T) {
for i := range 50 {
pw := PronouncablePasswordSeeded(int64(i), 16)
if pw == "" {
continue
}
first := rune(pw[0])
if !unicode.IsUpper(first) {
t.Errorf("expected first letter uppercase in %q (seed %d)", pw, i)
}
if !strings.ContainsRune(ppStartChar, first) {
t.Errorf("expected first letter from start-set in %q (seed %d)", pw, i)
}
}
}
func TestPpMakeSet(t *testing.T) {
set := ppMakeSet("ABC")
tst.AssertTrue(t, set['A'])
tst.AssertTrue(t, set['B'])
tst.AssertTrue(t, set['C'])
tst.AssertFalse(t, set['D'])
tst.AssertEqual(t, len(set), 3)
}
func TestPpMakeSetEmpty(t *testing.T) {
set := ppMakeSet("")
tst.AssertEqual(t, len(set), 0)
}
func TestPpCharType(t *testing.T) {
v, c := ppCharType('A')
tst.AssertTrue(t, v)
tst.AssertFalse(t, c)
v, c = ppCharType('B')
tst.AssertFalse(t, v)
tst.AssertTrue(t, c)
v, c = ppCharType('Y')
tst.AssertTrue(t, v)
tst.AssertFalse(t, c)
v, c = ppCharType('1')
tst.AssertFalse(t, v)
tst.AssertFalse(t, c)
}
func TestPpCharsetRemove(t *testing.T) {
set := ppMakeSet("AEIOU")
out := ppCharsetRemove("ABCDEFG", set, false)
tst.AssertEqual(t, out, "BCDFG")
}
func TestPpCharsetRemoveEmptyDisallowed(t *testing.T) {
set := ppMakeSet("AB")
out := ppCharsetRemove("AB", set, false)
// when result would be empty and allowEmpty=false, it returns the original
tst.AssertEqual(t, out, "AB")
}
func TestPpCharsetRemoveEmptyAllowed(t *testing.T) {
set := ppMakeSet("AB")
out := ppCharsetRemove("AB", set, true)
tst.AssertEqual(t, out, "")
}
func TestPpCharsetFilter(t *testing.T) {
set := ppMakeSet("AEIOU")
out := ppCharsetFilter("ABCDEFG", set, false)
tst.AssertEqual(t, out, "AE")
}
func TestPpCharsetFilterEmptyDisallowed(t *testing.T) {
set := ppMakeSet("XYZ")
out := ppCharsetFilter("ABC", set, false)
tst.AssertEqual(t, out, "ABC") // returns original when result empty & not allowed
}
func TestPpCharsetFilterEmptyAllowed(t *testing.T) {
set := ppMakeSet("XYZ")
out := ppCharsetFilter("ABC", set, true)
tst.AssertEqual(t, out, "")
}
func TestPronouncablePasswordContinuationFollowsRules(t *testing.T) {
// Make sure each continuation pair (lowercased) appears in ppContinuation
// Note: when a new segment starts (uppercase letter mid-string), the continuation
// check does not apply across the segment boundary.
for s := range 30 {
seed := int64(s)
pw := PronouncablePasswordSeeded(seed, 32)
if len(pw) < 2 {
continue
}
runes := []byte(strings.ToUpper(pw))
for i := 1; i < len(runes); i++ {
// Detect new segment (original char was uppercase and it's not the first char)
origUpper := pw[i] >= 'A' && pw[i] <= 'Z'
if origUpper && i > 0 {
continue
}
prev := runes[i-1]
cur := runes[i]
cont, ok := ppContinuation[prev]
if !ok {
t.Errorf("no continuation map for %q (pw=%q)", prev, pw)
continue
}
if !strings.ContainsRune(cont, rune(cur)) {
t.Errorf("invalid continuation %q -> %q in %q (seed %d)", prev, cur, pw, seed)
}
}
}
}
+35
View File
@@ -0,0 +1,35 @@
package cryptext
import (
"fmt"
"math/rand"
"testing"
)
func TestPronouncablePasswordExt(t *testing.T) {
for i := range 20 {
pw, entropy := PronouncablePasswordExt(rand.New(rand.NewSource(int64(i))), 16)
fmt.Printf("[%.2f] => %s\n", entropy, pw)
}
}
func TestPronouncablePasswordSeeded(t *testing.T) {
for i := range 20 {
pw := PronouncablePasswordSeeded(int64(i), 8)
fmt.Printf("%s\n", pw)
}
}
func TestPronouncablePassword(t *testing.T) {
for i := range 20 {
pw := PronouncablePassword(i + 1)
fmt.Printf("%s\n", pw)
}
}
func TestPronouncablePasswordWrongLen(t *testing.T) {
PronouncablePassword(0)
PronouncablePassword(-1)
PronouncablePassword(-2)
PronouncablePassword(-3)
}
+27
View File
@@ -0,0 +1,27 @@
package ctxext
import "context"
func Value[T any](ctx context.Context, key any) (T, bool) {
v := ctx.Value(key)
if v == nil {
return *new(T), false
}
if tv, ok := v.(T); !ok {
return *new(T), false
} else {
return tv, true
}
}
func ValueOrDefault[T any](ctx context.Context, key any, def T) T {
v := ctx.Value(key)
if v == nil {
return def
}
if tv, ok := v.(T); !ok {
return def
} else {
return tv
}
}
+237
View File
@@ -0,0 +1,237 @@
package ctxext
import (
"context"
"git.blackforestbytes.com/BlackForestBytes/goext/tst"
"testing"
)
type ctxKey string
const (
keyString ctxKey = "string-key"
keyInt ctxKey = "int-key"
keyStruct ctxKey = "struct-key"
keyPtr ctxKey = "ptr-key"
keyMissing ctxKey = "missing-key"
)
type sampleStruct struct {
Name string
N int
}
func TestValueStringPresent(t *testing.T) {
ctx := context.WithValue(context.Background(), keyString, "hello")
v, ok := Value[string](ctx, keyString)
tst.AssertEqual(t, ok, true)
tst.AssertEqual(t, v, "hello")
}
func TestValueIntPresent(t *testing.T) {
ctx := context.WithValue(context.Background(), keyInt, 42)
v, ok := Value[int](ctx, keyInt)
tst.AssertEqual(t, ok, true)
tst.AssertEqual(t, v, 42)
}
func TestValueStructPresent(t *testing.T) {
want := sampleStruct{Name: "abc", N: 7}
ctx := context.WithValue(context.Background(), keyStruct, want)
v, ok := Value[sampleStruct](ctx, keyStruct)
tst.AssertEqual(t, ok, true)
tst.AssertEqual(t, v.Name, "abc")
tst.AssertEqual(t, v.N, 7)
}
func TestValuePointerPresent(t *testing.T) {
want := &sampleStruct{Name: "ptr", N: 99}
ctx := context.WithValue(context.Background(), keyPtr, want)
v, ok := Value[*sampleStruct](ctx, keyPtr)
tst.AssertEqual(t, ok, true)
tst.AssertEqual(t, v == want, true)
tst.AssertEqual(t, v.Name, "ptr")
}
func TestValueMissing(t *testing.T) {
ctx := context.Background()
v, ok := Value[string](ctx, keyMissing)
tst.AssertEqual(t, ok, false)
tst.AssertEqual(t, v, "")
}
func TestValueMissingInt(t *testing.T) {
ctx := context.Background()
v, ok := Value[int](ctx, keyMissing)
tst.AssertEqual(t, ok, false)
tst.AssertEqual(t, v, 0)
}
func TestValueMissingStruct(t *testing.T) {
ctx := context.Background()
v, ok := Value[sampleStruct](ctx, keyMissing)
tst.AssertEqual(t, ok, false)
tst.AssertEqual(t, v.Name, "")
tst.AssertEqual(t, v.N, 0)
}
func TestValueMissingPointer(t *testing.T) {
ctx := context.Background()
v, ok := Value[*sampleStruct](ctx, keyMissing)
tst.AssertEqual(t, ok, false)
tst.AssertEqual(t, v == nil, true)
}
func TestValueWrongType(t *testing.T) {
ctx := context.WithValue(context.Background(), keyString, "hello")
v, ok := Value[int](ctx, keyString)
tst.AssertEqual(t, ok, false)
tst.AssertEqual(t, v, 0)
}
func TestValueWrongTypeStructToString(t *testing.T) {
ctx := context.WithValue(context.Background(), keyStruct, sampleStruct{Name: "x"})
v, ok := Value[string](ctx, keyStruct)
tst.AssertEqual(t, ok, false)
tst.AssertEqual(t, v, "")
}
func TestValueNilStoredAsInterface(t *testing.T) {
var stored *sampleStruct = nil
ctx := context.WithValue(context.Background(), keyPtr, stored)
v, ok := Value[*sampleStruct](ctx, keyPtr)
tst.AssertEqual(t, ok, true)
tst.AssertEqual(t, v == nil, true)
}
func TestValueEmptyString(t *testing.T) {
ctx := context.WithValue(context.Background(), keyString, "")
v, ok := Value[string](ctx, keyString)
tst.AssertEqual(t, ok, true)
tst.AssertEqual(t, v, "")
}
func TestValueZeroInt(t *testing.T) {
ctx := context.WithValue(context.Background(), keyInt, 0)
v, ok := Value[int](ctx, keyInt)
tst.AssertEqual(t, ok, true)
tst.AssertEqual(t, v, 0)
}
func TestValueWithStringKey(t *testing.T) {
type stringKey string
k := stringKey("my-key")
ctx := context.WithValue(context.Background(), k, "value")
v, ok := Value[string](ctx, k)
tst.AssertEqual(t, ok, true)
tst.AssertEqual(t, v, "value")
}
func TestValueOrDefaultPresent(t *testing.T) {
ctx := context.WithValue(context.Background(), keyString, "hello")
v := ValueOrDefault(ctx, keyString, "default")
tst.AssertEqual(t, v, "hello")
}
func TestValueOrDefaultIntPresent(t *testing.T) {
ctx := context.WithValue(context.Background(), keyInt, 42)
v := ValueOrDefault(ctx, keyInt, -1)
tst.AssertEqual(t, v, 42)
}
func TestValueOrDefaultMissing(t *testing.T) {
ctx := context.Background()
v := ValueOrDefault(ctx, keyMissing, "default")
tst.AssertEqual(t, v, "default")
}
func TestValueOrDefaultMissingInt(t *testing.T) {
ctx := context.Background()
v := ValueOrDefault(ctx, keyMissing, 99)
tst.AssertEqual(t, v, 99)
}
func TestValueOrDefaultMissingStruct(t *testing.T) {
ctx := context.Background()
def := sampleStruct{Name: "default", N: 1}
v := ValueOrDefault(ctx, keyMissing, def)
tst.AssertEqual(t, v.Name, "default")
tst.AssertEqual(t, v.N, 1)
}
func TestValueOrDefaultWrongType(t *testing.T) {
ctx := context.WithValue(context.Background(), keyString, "hello")
v := ValueOrDefault(ctx, keyString, 7)
tst.AssertEqual(t, v, 7)
}
func TestValueOrDefaultWrongTypeStruct(t *testing.T) {
ctx := context.WithValue(context.Background(), keyStruct, sampleStruct{Name: "x"})
def := "fallback"
v := ValueOrDefault(ctx, keyStruct, def)
tst.AssertEqual(t, v, "fallback")
}
func TestValueOrDefaultEmptyStringStored(t *testing.T) {
ctx := context.WithValue(context.Background(), keyString, "")
v := ValueOrDefault(ctx, keyString, "default")
tst.AssertEqual(t, v, "")
}
func TestValueOrDefaultZeroIntStored(t *testing.T) {
ctx := context.WithValue(context.Background(), keyInt, 0)
v := ValueOrDefault(ctx, keyInt, 99)
tst.AssertEqual(t, v, 0)
}
func TestValueOrDefaultPointerPresent(t *testing.T) {
want := &sampleStruct{Name: "p", N: 5}
ctx := context.WithValue(context.Background(), keyPtr, want)
def := &sampleStruct{Name: "def", N: 0}
v := ValueOrDefault(ctx, keyPtr, def)
tst.AssertEqual(t, v == want, true)
}
func TestValueOrDefaultPointerMissing(t *testing.T) {
ctx := context.Background()
def := &sampleStruct{Name: "def", N: 0}
v := ValueOrDefault(ctx, keyMissing, def)
tst.AssertEqual(t, v == def, true)
}
func TestValueOrDefaultNilPointerStored(t *testing.T) {
var stored *sampleStruct = nil
ctx := context.WithValue(context.Background(), keyPtr, stored)
def := &sampleStruct{Name: "def"}
v := ValueOrDefault(ctx, keyPtr, def)
tst.AssertEqual(t, v == nil, true)
}
func TestValueNestedContext(t *testing.T) {
ctx := context.WithValue(context.Background(), keyString, "outer")
ctx = context.WithValue(ctx, keyInt, 123)
ctx = context.WithValue(ctx, keyString, "inner")
vs, oks := Value[string](ctx, keyString)
tst.AssertEqual(t, oks, true)
tst.AssertEqual(t, vs, "inner")
vi, oki := Value[int](ctx, keyInt)
tst.AssertEqual(t, oki, true)
tst.AssertEqual(t, vi, 123)
}
func TestValueDifferentKeyTypesDoNotCollide(t *testing.T) {
type keyA string
type keyB string
ctx := context.WithValue(context.Background(), keyA("k"), "a-val")
ctx = context.WithValue(ctx, keyB("k"), "b-val")
va, oka := Value[string](ctx, keyA("k"))
tst.AssertEqual(t, oka, true)
tst.AssertEqual(t, va, "a-val")
vb, okb := Value[string](ctx, keyB("k"))
tst.AssertEqual(t, okb, true)
tst.AssertEqual(t, vb, "b-val")
}
+10
View File
@@ -6,3 +6,13 @@ const (
SortASC SortDirection = "ASC"
SortDESC SortDirection = "DESC"
)
func (sd SortDirection) ToMongo() int {
if sd == SortASC {
return 1
} else if sd == SortDESC {
return -1
} else {
return 0
}
}
+29
View File
@@ -0,0 +1,29 @@
package cursortoken
import (
"git.blackforestbytes.com/BlackForestBytes/goext/tst"
"testing"
)
func TestSortDirectionToMongoASC(t *testing.T) {
tst.AssertEqual(t, SortASC.ToMongo(), 1)
}
func TestSortDirectionToMongoDESC(t *testing.T) {
tst.AssertEqual(t, SortDESC.ToMongo(), -1)
}
func TestSortDirectionToMongoEmpty(t *testing.T) {
var sd SortDirection
tst.AssertEqual(t, sd.ToMongo(), 0)
}
func TestSortDirectionToMongoUnknown(t *testing.T) {
sd := SortDirection("xyz")
tst.AssertEqual(t, sd.ToMongo(), 0)
}
func TestSortDirectionConstants(t *testing.T) {
tst.AssertEqual(t, string(SortASC), "ASC")
tst.AssertEqual(t, string(SortDESC), "DESC")
}
+10 -4
View File
@@ -1,10 +1,16 @@
package cursortoken
import (
"go.mongodb.org/mongo-driver/mongo"
"context"
"go.mongodb.org/mongo-driver/v2/mongo"
)
type Filter interface {
FilterQuery() mongo.Pipeline
Pagination() (string, SortDirection, string, SortDirection)
type RawFilter interface {
FilterQuery(ctx context.Context) mongo.Pipeline
}
type Filter interface {
FilterQuery(ctx context.Context) mongo.Pipeline
Pagination(ctx context.Context) (string, SortDirection, string, SortDirection)
}
+50 -139
View File
@@ -3,12 +3,18 @@ package cursortoken
import (
"encoding/base32"
"encoding/json"
"errors"
"go.mongodb.org/mongo-driver/bson/primitive"
"git.blackforestbytes.com/BlackForestBytes/goext/exerr"
"strconv"
"strings"
"time"
)
type CursorToken interface {
Token() string
IsStart() bool
IsEnd() bool
}
type Mode string
const (
@@ -24,97 +30,6 @@ type Extra struct {
PageSize *int
}
type CursorToken struct {
Mode Mode
ValuePrimary string
ValueSecondary string
Direction SortDirection
DirectionSecondary SortDirection
PageSize int
Extra Extra
}
type cursorTokenSerialize struct {
ValuePrimary *string `json:"v1,omitempty"`
ValueSecondary *string `json:"v2,omitempty"`
Direction *SortDirection `json:"dir,omitempty"`
DirectionSecondary *SortDirection `json:"dir2,omitempty"`
PageSize *int `json:"size,omitempty"`
ExtraTimestamp *time.Time `json:"ts,omitempty"`
ExtraId *string `json:"id,omitempty"`
ExtraPage *int `json:"pg,omitempty"`
ExtraPageSize *int `json:"sz,omitempty"`
}
func Start() CursorToken {
return CursorToken{
Mode: CTMStart,
ValuePrimary: "",
ValueSecondary: "",
Direction: "",
DirectionSecondary: "",
PageSize: 0,
Extra: Extra{},
}
}
func End() CursorToken {
return CursorToken{
Mode: CTMEnd,
ValuePrimary: "",
ValueSecondary: "",
Direction: "",
DirectionSecondary: "",
PageSize: 0,
Extra: Extra{},
}
}
func (c *CursorToken) Token() string {
if c.Mode == CTMStart {
return "@start"
}
if c.Mode == CTMEnd {
return "@end"
}
// We kinda manually implement omitempty for the CursorToken here
// because omitempty does not work for time.Time and otherwise we would always
// get weird time values when decoding a token that initially didn't have an Timestamp set
// For this usecase we treat Unix=0 as an empty timestamp
sertok := cursorTokenSerialize{}
if c.ValuePrimary != "" {
sertok.ValuePrimary = &c.ValuePrimary
}
if c.ValueSecondary != "" {
sertok.ValueSecondary = &c.ValueSecondary
}
if c.Direction != "" {
sertok.Direction = &c.Direction
}
if c.DirectionSecondary != "" {
sertok.DirectionSecondary = &c.DirectionSecondary
}
if c.PageSize != 0 {
sertok.PageSize = &c.PageSize
}
sertok.ExtraTimestamp = c.Extra.Timestamp
sertok.ExtraId = c.Extra.Id
sertok.ExtraPage = c.Extra.Page
sertok.ExtraPageSize = c.Extra.PageSize
body, err := json.Marshal(sertok)
if err != nil {
panic(err)
}
return "tok_" + base32.StdEncoding.EncodeToString(body)
}
func Decode(tok string) (CursorToken, error) {
if tok == "" {
return Start(), nil
@@ -125,60 +40,56 @@ func Decode(tok string) (CursorToken, error) {
if strings.ToLower(tok) == "@end" {
return End(), nil
}
if !strings.HasPrefix(tok, "tok_") {
return CursorToken{}, errors.New("could not decode token, missing prefix")
if strings.ToLower(tok) == "$end" {
return PageEnd(), nil
}
if strings.HasPrefix(tok, "$") && len(tok) > 1 {
n, err := strconv.ParseInt(tok[1:], 10, 64)
if err != nil {
return nil, exerr.Wrap(err, "failed to deserialize token").Str("token", tok).WithType(exerr.TypeCursorTokenDecode).Build()
}
return Page(int(n)), nil
}
body, err := base32.StdEncoding.DecodeString(tok[len("tok_"):])
if err != nil {
return CursorToken{}, err
}
if strings.HasPrefix(tok, "tok_") {
var tokenDeserialize cursorTokenSerialize
err = json.Unmarshal(body, &tokenDeserialize)
if err != nil {
return CursorToken{}, err
}
body, err := base32.StdEncoding.DecodeString(tok[len("tok_"):])
if err != nil {
return nil, err
}
token := CursorToken{Mode: CTMNormal}
var tokenDeserialize cursorTokenKeySortSerialize
err = json.Unmarshal(body, &tokenDeserialize)
if err != nil {
return nil, exerr.Wrap(err, "failed to deserialize token").Str("token", tok).WithType(exerr.TypeCursorTokenDecode).Build()
}
if tokenDeserialize.ValuePrimary != nil {
token.ValuePrimary = *tokenDeserialize.ValuePrimary
}
if tokenDeserialize.ValueSecondary != nil {
token.ValueSecondary = *tokenDeserialize.ValueSecondary
}
if tokenDeserialize.Direction != nil {
token.Direction = *tokenDeserialize.Direction
}
if tokenDeserialize.DirectionSecondary != nil {
token.DirectionSecondary = *tokenDeserialize.DirectionSecondary
}
if tokenDeserialize.PageSize != nil {
token.PageSize = *tokenDeserialize.PageSize
}
token := CTKeySort{Mode: CTMNormal}
token.Extra.Timestamp = tokenDeserialize.ExtraTimestamp
token.Extra.Id = tokenDeserialize.ExtraId
token.Extra.Page = tokenDeserialize.ExtraPage
token.Extra.PageSize = tokenDeserialize.ExtraPageSize
if tokenDeserialize.ValuePrimary != nil {
token.ValuePrimary = *tokenDeserialize.ValuePrimary
}
if tokenDeserialize.ValueSecondary != nil {
token.ValueSecondary = *tokenDeserialize.ValueSecondary
}
if tokenDeserialize.Direction != nil {
token.Direction = *tokenDeserialize.Direction
}
if tokenDeserialize.DirectionSecondary != nil {
token.DirectionSecondary = *tokenDeserialize.DirectionSecondary
}
if tokenDeserialize.PageSize != nil {
token.PageSize = *tokenDeserialize.PageSize
}
return token, nil
}
token.Extra.Timestamp = tokenDeserialize.ExtraTimestamp
token.Extra.Id = tokenDeserialize.ExtraId
token.Extra.Page = tokenDeserialize.ExtraPage
token.Extra.PageSize = tokenDeserialize.ExtraPageSize
return token, nil
func (c *CursorToken) ValuePrimaryObjectId() (primitive.ObjectID, bool) {
if oid, err := primitive.ObjectIDFromHex(c.ValuePrimary); err == nil {
return oid, true
} else {
return primitive.ObjectID{}, false
}
}
func (c *CursorToken) ValueSecondaryObjectId() (primitive.ObjectID, bool) {
if oid, err := primitive.ObjectIDFromHex(c.ValueSecondary); err == nil {
return oid, true
} else {
return primitive.ObjectID{}, false
return nil, exerr.New(exerr.TypeCursorTokenDecode, "could not decode token, missing/unknown prefix").Str("token", tok).Build()
}
}
+137
View File
@@ -0,0 +1,137 @@
package cursortoken
import (
"encoding/base32"
"encoding/json"
"time"
"go.mongodb.org/mongo-driver/v2/bson"
)
type CTKeySort struct {
Mode Mode
ValuePrimary string
ValueSecondary string
Direction SortDirection
DirectionSecondary SortDirection
PageSize int
Extra Extra
}
type cursorTokenKeySortSerialize struct {
ValuePrimary *string `json:"v1,omitempty"`
ValueSecondary *string `json:"v2,omitempty"`
Direction *SortDirection `json:"dir,omitempty"`
DirectionSecondary *SortDirection `json:"dir2,omitempty"`
PageSize *int `json:"size,omitempty"`
ExtraTimestamp *time.Time `json:"ts,omitempty"`
ExtraId *string `json:"id,omitempty"`
ExtraPage *int `json:"pg,omitempty"`
ExtraPageSize *int `json:"sz,omitempty"`
}
func NewKeySortToken(valuePrimary string, valueSecondary string, direction SortDirection, directionSecondary SortDirection, pageSize int, extra Extra) CursorToken {
return CTKeySort{
Mode: CTMNormal,
ValuePrimary: valuePrimary,
ValueSecondary: valueSecondary,
Direction: direction,
DirectionSecondary: directionSecondary,
PageSize: pageSize,
Extra: extra,
}
}
func Start() CursorToken {
return CTKeySort{
Mode: CTMStart,
ValuePrimary: "",
ValueSecondary: "",
Direction: "",
DirectionSecondary: "",
PageSize: 0,
Extra: Extra{},
}
}
func End() CursorToken {
return CTKeySort{
Mode: CTMEnd,
ValuePrimary: "",
ValueSecondary: "",
Direction: "",
DirectionSecondary: "",
PageSize: 0,
Extra: Extra{},
}
}
func (c CTKeySort) Token() string {
if c.Mode == CTMStart {
return "@start"
}
if c.Mode == CTMEnd {
return "@end"
}
// We kinda manually implement omitempty for the CursorToken here
// because omitempty does not work for time.Time and otherwise we would always
// get weird time values when decoding a token that initially didn't have an Timestamp set
// For this usecase we treat Unix=0 as an empty timestamp
sertok := cursorTokenKeySortSerialize{}
if c.ValuePrimary != "" {
sertok.ValuePrimary = &c.ValuePrimary
}
if c.ValueSecondary != "" {
sertok.ValueSecondary = &c.ValueSecondary
}
if c.Direction != "" {
sertok.Direction = &c.Direction
}
if c.DirectionSecondary != "" {
sertok.DirectionSecondary = &c.DirectionSecondary
}
if c.PageSize != 0 {
sertok.PageSize = &c.PageSize
}
sertok.ExtraTimestamp = c.Extra.Timestamp
sertok.ExtraId = c.Extra.Id
sertok.ExtraPage = c.Extra.Page
sertok.ExtraPageSize = c.Extra.PageSize
body, err := json.Marshal(sertok)
if err != nil {
panic(err)
}
return "tok_" + base32.StdEncoding.EncodeToString(body)
}
func (c CTKeySort) IsEnd() bool {
return c.Mode == CTMEnd
}
func (c CTKeySort) IsStart() bool {
return c.Mode == CTMStart
}
func (c CTKeySort) valuePrimaryObjectId() (bson.ObjectID, bool) {
if oid, err := bson.ObjectIDFromHex(c.ValuePrimary); err == nil {
return oid, true
} else {
return bson.ObjectID{}, false
}
}
func (c CTKeySort) valueSecondaryObjectId() (bson.ObjectID, bool) {
if oid, err := bson.ObjectIDFromHex(c.ValueSecondary); err == nil {
return oid, true
} else {
return bson.ObjectID{}, false
}
}
+136
View File
@@ -0,0 +1,136 @@
package cursortoken
import (
"git.blackforestbytes.com/BlackForestBytes/goext/tst"
"strings"
"testing"
"time"
)
func TestStartToken(t *testing.T) {
tok := Start()
tst.AssertEqual(t, tok.Token(), "@start")
tst.AssertTrue(t, tok.IsStart())
tst.AssertFalse(t, tok.IsEnd())
}
func TestEndToken(t *testing.T) {
tok := End()
tst.AssertEqual(t, tok.Token(), "@end")
tst.AssertTrue(t, tok.IsEnd())
tst.AssertFalse(t, tok.IsStart())
}
func TestNewKeySortTokenBasic(t *testing.T) {
tok := NewKeySortToken("alpha", "beta", SortASC, SortDESC, 50, Extra{})
tst.AssertFalse(t, tok.IsEnd())
tst.AssertFalse(t, tok.IsStart())
str := tok.Token()
tst.AssertTrue(t, strings.HasPrefix(str, "tok_"))
}
func TestNewKeySortTokenRoundTrip(t *testing.T) {
original := NewKeySortToken("primary-val", "secondary-val", SortASC, SortDESC, 25, Extra{})
encoded := original.Token()
decoded, err := Decode(encoded)
tst.AssertNoErr(t, err)
ks, ok := decoded.(CTKeySort)
tst.AssertTrue(t, ok)
tst.AssertEqual(t, ks.ValuePrimary, "primary-val")
tst.AssertEqual(t, ks.ValueSecondary, "secondary-val")
tst.AssertEqual(t, ks.Direction, SortASC)
tst.AssertEqual(t, ks.DirectionSecondary, SortDESC)
tst.AssertEqual(t, ks.PageSize, 25)
tst.AssertEqual(t, ks.Mode, CTMNormal)
}
func TestKeySortTokenWithExtra(t *testing.T) {
ts := time.Date(2024, 6, 15, 10, 30, 0, 0, time.UTC)
id := "object-id-123"
page := 7
pageSize := 42
original := NewKeySortToken("p", "s", SortDESC, SortASC, 10, Extra{
Timestamp: &ts,
Id: &id,
Page: &page,
PageSize: &pageSize,
})
encoded := original.Token()
decoded, err := Decode(encoded)
tst.AssertNoErr(t, err)
ks, ok := decoded.(CTKeySort)
tst.AssertTrue(t, ok)
tst.AssertTrue(t, ks.Extra.Timestamp != nil)
tst.AssertTrue(t, ks.Extra.Timestamp.Equal(ts))
tst.AssertDeRefEqual(t, ks.Extra.Id, "object-id-123")
tst.AssertDeRefEqual(t, ks.Extra.Page, 7)
tst.AssertDeRefEqual(t, ks.Extra.PageSize, 42)
}
func TestKeySortTokenStartRoundTrip(t *testing.T) {
original := Start()
decoded, err := Decode(original.Token())
tst.AssertNoErr(t, err)
tst.AssertTrue(t, decoded.IsStart())
tst.AssertFalse(t, decoded.IsEnd())
}
func TestKeySortTokenEndRoundTrip(t *testing.T) {
original := End()
decoded, err := Decode(original.Token())
tst.AssertNoErr(t, err)
tst.AssertTrue(t, decoded.IsEnd())
tst.AssertFalse(t, decoded.IsStart())
}
func TestKeySortTokenEmptyValues(t *testing.T) {
tok := CTKeySort{Mode: CTMNormal}
encoded := tok.Token()
tst.AssertTrue(t, strings.HasPrefix(encoded, "tok_"))
decoded, err := Decode(encoded)
tst.AssertNoErr(t, err)
ks, ok := decoded.(CTKeySort)
tst.AssertTrue(t, ok)
tst.AssertEqual(t, ks.ValuePrimary, "")
tst.AssertEqual(t, ks.ValueSecondary, "")
tst.AssertEqual(t, ks.Direction, SortDirection(""))
tst.AssertEqual(t, ks.DirectionSecondary, SortDirection(""))
tst.AssertEqual(t, ks.PageSize, 0)
}
func TestKeySortTokenOnlyTimestamp(t *testing.T) {
ts := time.Date(2020, 1, 2, 3, 4, 5, 0, time.UTC)
tok := CTKeySort{
Mode: CTMNormal,
Extra: Extra{Timestamp: &ts},
}
decoded, err := Decode(tok.Token())
tst.AssertNoErr(t, err)
ks, ok := decoded.(CTKeySort)
tst.AssertTrue(t, ok)
tst.AssertTrue(t, ks.Extra.Timestamp != nil)
tst.AssertTrue(t, ks.Extra.Timestamp.Equal(ts))
tst.AssertTrue(t, ks.Extra.Id == nil)
tst.AssertTrue(t, ks.Extra.Page == nil)
tst.AssertTrue(t, ks.Extra.PageSize == nil)
}
func TestKeySortTokenSpecialChars(t *testing.T) {
original := NewKeySortToken("hello world / @!#$%", "äöü€", SortASC, SortASC, 1, Extra{})
decoded, err := Decode(original.Token())
tst.AssertNoErr(t, err)
ks, ok := decoded.(CTKeySort)
tst.AssertTrue(t, ok)
tst.AssertEqual(t, ks.ValuePrimary, "hello world / @!#$%")
tst.AssertEqual(t, ks.ValueSecondary, "äöü€")
}
+41
View File
@@ -0,0 +1,41 @@
package cursortoken
import "strconv"
type CTPaginated struct {
Mode Mode
Page int
}
func Page(p int) CursorToken {
return CTPaginated{
Mode: CTMNormal,
Page: p,
}
}
func PageEnd() CursorToken {
return CTPaginated{
Mode: CTMEnd,
Page: 0,
}
}
func (c CTPaginated) Token() string {
if c.Mode == CTMStart {
return "$1"
}
if c.Mode == CTMEnd {
return "$end"
}
return "$" + strconv.Itoa(c.Page)
}
func (c CTPaginated) IsEnd() bool {
return c.Mode == CTMEnd
}
func (c CTPaginated) IsStart() bool {
return c.Mode == CTMStart || c.Page == 1
}
+61
View File
@@ -0,0 +1,61 @@
package cursortoken
import (
"git.blackforestbytes.com/BlackForestBytes/goext/tst"
"testing"
)
func TestPageToken(t *testing.T) {
tok := Page(5)
tst.AssertEqual(t, tok.Token(), "$5")
tst.AssertFalse(t, tok.IsEnd())
tst.AssertFalse(t, tok.IsStart())
}
func TestPageTokenOne(t *testing.T) {
tok := Page(1)
tst.AssertEqual(t, tok.Token(), "$1")
tst.AssertFalse(t, tok.IsEnd())
tst.AssertTrue(t, tok.IsStart())
}
func TestPageTokenLarge(t *testing.T) {
tok := Page(123456)
tst.AssertEqual(t, tok.Token(), "$123456")
}
func TestPageTokenZero(t *testing.T) {
tok := Page(0)
tst.AssertEqual(t, tok.Token(), "$0")
tst.AssertFalse(t, tok.IsEnd())
tst.AssertFalse(t, tok.IsStart())
}
func TestPageEndToken(t *testing.T) {
tok := PageEnd()
tst.AssertEqual(t, tok.Token(), "$end")
tst.AssertTrue(t, tok.IsEnd())
tst.AssertFalse(t, tok.IsStart())
}
func TestPaginatedStartMode(t *testing.T) {
tok := CTPaginated{Mode: CTMStart, Page: 0}
tst.AssertEqual(t, tok.Token(), "$1")
tst.AssertTrue(t, tok.IsStart())
tst.AssertFalse(t, tok.IsEnd())
}
func TestPaginatedEndMode(t *testing.T) {
tok := CTPaginated{Mode: CTMEnd, Page: 99}
tst.AssertEqual(t, tok.Token(), "$end")
tst.AssertTrue(t, tok.IsEnd())
}
func TestPaginatedRoundTrip(t *testing.T) {
for _, page := range []int{2, 3, 7, 100, 9999} {
tok := Page(page)
decoded, err := Decode(tok.Token())
tst.AssertNoErr(t, err)
tst.AssertEqual(t, decoded.Token(), tok.Token())
}
}
+125
View File
@@ -0,0 +1,125 @@
package cursortoken
import (
"git.blackforestbytes.com/BlackForestBytes/goext/tst"
"testing"
)
func TestDecodeEmpty(t *testing.T) {
tok, err := Decode("")
tst.AssertNoErr(t, err)
tst.AssertTrue(t, tok.IsStart())
tst.AssertFalse(t, tok.IsEnd())
tst.AssertEqual(t, tok.Token(), "@start")
}
func TestDecodeAtStart(t *testing.T) {
tok, err := Decode("@start")
tst.AssertNoErr(t, err)
tst.AssertTrue(t, tok.IsStart())
tst.AssertFalse(t, tok.IsEnd())
}
func TestDecodeAtStartUppercase(t *testing.T) {
tok, err := Decode("@START")
tst.AssertNoErr(t, err)
tst.AssertTrue(t, tok.IsStart())
}
func TestDecodeAtStartMixedCase(t *testing.T) {
tok, err := Decode("@StArT")
tst.AssertNoErr(t, err)
tst.AssertTrue(t, tok.IsStart())
}
func TestDecodeAtEnd(t *testing.T) {
tok, err := Decode("@end")
tst.AssertNoErr(t, err)
tst.AssertTrue(t, tok.IsEnd())
tst.AssertFalse(t, tok.IsStart())
}
func TestDecodeAtEndUppercase(t *testing.T) {
tok, err := Decode("@END")
tst.AssertNoErr(t, err)
tst.AssertTrue(t, tok.IsEnd())
}
func TestDecodeDollarEnd(t *testing.T) {
tok, err := Decode("$end")
tst.AssertNoErr(t, err)
tst.AssertTrue(t, tok.IsEnd())
_, ok := tok.(CTPaginated)
tst.AssertTrue(t, ok)
}
func TestDecodeDollarEndUppercase(t *testing.T) {
tok, err := Decode("$END")
tst.AssertNoErr(t, err)
tst.AssertTrue(t, tok.IsEnd())
}
func TestDecodeDollarPage(t *testing.T) {
tok, err := Decode("$5")
tst.AssertNoErr(t, err)
pg, ok := tok.(CTPaginated)
tst.AssertTrue(t, ok)
tst.AssertEqual(t, pg.Page, 5)
tst.AssertEqual(t, pg.Mode, CTMNormal)
}
func TestDecodeDollarPageOne(t *testing.T) {
tok, err := Decode("$1")
tst.AssertNoErr(t, err)
tst.AssertTrue(t, tok.IsStart())
pg, ok := tok.(CTPaginated)
tst.AssertTrue(t, ok)
tst.AssertEqual(t, pg.Page, 1)
}
func TestDecodeDollarPageInvalid(t *testing.T) {
_, err := Decode("$abc")
if err == nil {
t.Fatalf("expected error for invalid page")
}
}
func TestDecodeUnknownPrefix(t *testing.T) {
_, err := Decode("foobar")
if err == nil {
t.Fatalf("expected error for unknown prefix")
}
}
func TestDecodeInvalidBase32(t *testing.T) {
_, err := Decode("tok_!!!")
if err == nil {
t.Fatalf("expected error for invalid base32 body")
}
}
func TestDecodeInvalidJSON(t *testing.T) {
// "tok_" prefix with valid base32 but invalid JSON content
_, err := Decode("tok_NBSWY3DP")
if err == nil {
t.Fatalf("expected error for invalid json body")
}
}
func TestDecodeJustDollar(t *testing.T) {
// "$" alone (length == 1) should fall through to the unknown-prefix branch
_, err := Decode("$")
if err == nil {
t.Fatalf("expected error for bare $")
}
}
func TestDecodeKnownTokenContent(t *testing.T) {
tok := NewKeySortToken("k1", "k2", SortASC, SortDESC, 33, Extra{})
encoded := tok.Token()
decoded, err := Decode(encoded)
tst.AssertNoErr(t, err)
tst.AssertEqual(t, decoded.Token(), encoded)
}
+230
View File
@@ -0,0 +1,230 @@
package dataext
import (
"context"
"iter"
"sync"
"time"
"git.blackforestbytes.com/BlackForestBytes/goext/langext"
"git.blackforestbytes.com/BlackForestBytes/goext/syncext"
"github.com/rs/xid"
)
// Broadcaster is a simple Broadcaster channel
// This is a simpler interface over Broadcaster - which does not have distinct namespaces
type Broadcaster[TData any] struct {
masterLock *sync.Mutex
subscriptions []*broadcastSubscription[TData]
}
type BroadcastSubscription interface {
Unsubscribe()
}
type broadcastSubscription[TData any] struct {
ID string
parent *Broadcaster[TData]
subLock *sync.Mutex
Func func(TData)
Chan chan TData
UnsubChan chan bool
}
func (p *broadcastSubscription[TData]) Unsubscribe() {
p.parent.unsubscribe(p)
}
func NewBroadcaster[TData any](capacity int) *Broadcaster[TData] {
return &Broadcaster[TData]{
masterLock: &sync.Mutex{},
subscriptions: make([]*broadcastSubscription[TData], 0, capacity),
}
}
func (bb *Broadcaster[TData]) SubscriberCount() int {
bb.masterLock.Lock()
defer bb.masterLock.Unlock()
return len(bb.subscriptions)
}
// Publish sends `data` to all subscriber
// But unbuffered - if one is currently not listening, we skip (the actualReceiver < subscriber)
func (bb *Broadcaster[TData]) Publish(data TData) (subscriber int, actualReceiver int) {
bb.masterLock.Lock()
subs := langext.ArrCopy(bb.subscriptions)
bb.masterLock.Unlock()
subscriber = len(subs)
actualReceiver = 0
for _, sub := range subs {
func() {
sub.subLock.Lock()
defer sub.subLock.Unlock()
if sub.Func != nil {
go func() { sub.Func(data) }()
actualReceiver++
} else if sub.Chan != nil {
msgSent := syncext.WriteNonBlocking(sub.Chan, data)
if msgSent {
actualReceiver++
}
}
}()
}
return subscriber, actualReceiver
}
// PublishWithContext sends `data` to all subscriber
// buffered - if one is currently not listening, we wait (but error out when the context runs out)
func (bb *Broadcaster[TData]) PublishWithContext(ctx context.Context, data TData) (subscriber int, actualReceiver int, err error) {
bb.masterLock.Lock()
subs := langext.ArrCopy(bb.subscriptions)
bb.masterLock.Unlock()
subscriber = len(subs)
actualReceiver = 0
for _, sub := range subs {
err := func() error {
sub.subLock.Lock()
defer sub.subLock.Unlock()
if err := ctx.Err(); err != nil {
return err
}
if sub.Func != nil {
go func() { sub.Func(data) }()
actualReceiver++
} else if sub.Chan != nil {
err := syncext.WriteChannelWithContext(ctx, sub.Chan, data)
if err != nil {
return err
}
actualReceiver++
}
return nil
}()
if err != nil {
return subscriber, actualReceiver, err
}
}
return subscriber, actualReceiver, nil
}
// PublishWithTimeout sends `data` to all subscriber
// buffered - if one is currently not listening, we wait (but wait at most `timeout` - if the timeout is exceeded then actualReceiver < subscriber)
func (bb *Broadcaster[TData]) PublishWithTimeout(data TData, timeout time.Duration) (subscriber int, actualReceiver int) {
bb.masterLock.Lock()
subs := langext.ArrCopy(bb.subscriptions)
bb.masterLock.Unlock()
subscriber = len(subs)
actualReceiver = 0
for _, sub := range subs {
func() {
sub.subLock.Lock()
defer sub.subLock.Unlock()
if sub.Func != nil {
go func() { sub.Func(data) }()
actualReceiver++
} else if sub.Chan != nil {
ok := syncext.WriteChannelWithTimeout(sub.Chan, data, timeout)
if ok {
actualReceiver++
}
}
}()
}
return subscriber, actualReceiver
}
func (bb *Broadcaster[TData]) SubscribeByCallback(fn func(TData)) BroadcastSubscription {
bb.masterLock.Lock()
defer bb.masterLock.Unlock()
sub := &broadcastSubscription[TData]{ID: xid.New().String(), parent: bb, subLock: &sync.Mutex{}, Func: fn, UnsubChan: nil}
bb.subscriptions = append(bb.subscriptions, sub)
return sub
}
func (bb *Broadcaster[TData]) SubscribeByChan(chanBufferSize int) (chan TData, BroadcastSubscription) {
bb.masterLock.Lock()
defer bb.masterLock.Unlock()
msgCh := make(chan TData, chanBufferSize)
sub := &broadcastSubscription[TData]{ID: xid.New().String(), parent: bb, subLock: &sync.Mutex{}, Chan: msgCh, UnsubChan: nil}
bb.subscriptions = append(bb.subscriptions, sub)
return msgCh, sub
}
func (bb *Broadcaster[TData]) SubscribeByIter(chanBufferSize int) (iter.Seq[TData], BroadcastSubscription) {
bb.masterLock.Lock()
defer bb.masterLock.Unlock()
msgCh := make(chan TData, chanBufferSize)
unsubChan := make(chan bool, 8)
sub := &broadcastSubscription[TData]{ID: xid.New().String(), parent: bb, subLock: &sync.Mutex{}, Chan: msgCh, UnsubChan: unsubChan}
bb.subscriptions = append(bb.subscriptions, sub)
iterFun := func(yield func(TData) bool) {
for {
select {
case msg := <-msgCh:
if !yield(msg) {
sub.Unsubscribe()
return
}
case <-sub.UnsubChan:
sub.Unsubscribe()
return
}
}
}
return iterFun, sub
}
func (bb *Broadcaster[TData]) unsubscribe(p *broadcastSubscription[TData]) {
bb.masterLock.Lock()
defer bb.masterLock.Unlock()
p.subLock.Lock()
defer p.subLock.Unlock()
if p.Chan != nil {
close(p.Chan)
p.Chan = nil
}
if p.UnsubChan != nil {
syncext.WriteNonBlocking(p.UnsubChan, true)
close(p.UnsubChan)
p.UnsubChan = nil
}
bb.subscriptions = langext.ArrFilter(bb.subscriptions, func(v *broadcastSubscription[TData]) bool {
return v.ID != p.ID
})
}
+342
View File
@@ -0,0 +1,342 @@
package dataext
import (
"context"
"sync"
"testing"
"time"
)
func TestNewBroadcast(t *testing.T) {
bb := NewBroadcaster[string](10)
if bb == nil {
t.Fatal("NewBroadcaster returned nil")
}
if bb.masterLock == nil {
t.Fatal("masterLock is nil")
}
if bb.subscriptions == nil {
t.Fatal("subscriptions is nil")
}
}
func TestBroadcast_SubscribeByCallback(t *testing.T) {
bb := NewBroadcaster[string](10)
var received string
var wg sync.WaitGroup
wg.Add(1)
callback := func(msg string) {
received = msg
wg.Done()
}
sub := bb.SubscribeByCallback(callback)
defer sub.Unsubscribe()
// Publish a message
subs, receivers := bb.Publish("hello")
if subs != 1 {
t.Fatalf("Expected 1 subscriber, got %d", subs)
}
if receivers != 1 {
t.Fatalf("Expected 1 receiver, got %d", receivers)
}
// Wait for the callback to be executed
wg.Wait()
if received != "hello" {
t.Fatalf("Expected to receive 'hello', got '%s'", received)
}
}
func TestBroadcast_SubscribeByChan(t *testing.T) {
bb := NewBroadcaster[string](10)
ch, sub := bb.SubscribeByChan(1)
defer sub.Unsubscribe()
// Publish a message
subs, receivers := bb.Publish("hello")
if subs != 1 {
t.Fatalf("Expected 1 subscriber, got %d", subs)
}
if receivers != 1 {
t.Fatalf("Expected 1 receiver, got %d", receivers)
}
// Read from the channel with a timeout to avoid blocking
select {
case msg := <-ch:
if msg != "hello" {
t.Fatalf("Expected to receive 'hello', got '%s'", msg)
}
case <-time.After(time.Second):
t.Fatal("Timed out waiting for message")
}
}
func TestBroadcast_SubscribeByIter(t *testing.T) {
bb := NewBroadcaster[string](10)
iterSeq, sub := bb.SubscribeByIter(1)
defer sub.Unsubscribe()
// Channel to communicate when message is received
done := make(chan bool)
goroutineDone := make(chan struct{})
received := false
// Start a goroutine to use the iterator
go func() {
defer close(goroutineDone)
for msg := range iterSeq {
if msg == "hello" {
received = true
done <- true
return // Stop iteration — triggers Unsubscribe via yield returning false
}
}
}()
// Give time for the iterator to start
time.Sleep(100 * time.Millisecond)
// Publish a message
bb.Publish("hello")
// Wait for the message to be received or timeout
select {
case <-done:
if !received {
t.Fatal("Message was received but not 'hello'")
}
case <-time.After(time.Second):
t.Fatal("Timed out waiting for message")
}
// Wait for the goroutine to fully exit so Unsubscribe (triggered by the
// iterator cleanup when yield returns false) has completed.
select {
case <-goroutineDone:
case <-time.After(time.Second):
t.Fatal("Timed out waiting for goroutine to finish")
}
subCount := bb.SubscriberCount()
if subCount != 0 {
t.Fatalf("Expected 0 receivers, got %d", subCount)
}
}
func TestBroadcast_Publish(t *testing.T) {
bb := NewBroadcaster[string](10)
// Test publishing with no subscribers
subs, receivers := bb.Publish("hello")
if subs != 0 {
t.Fatalf("Expected 0 subscribers, got %d", subs)
}
if receivers != 0 {
t.Fatalf("Expected 0 receivers, got %d", receivers)
}
// Add a subscriber
ch, sub := bb.SubscribeByChan(1)
defer sub.Unsubscribe()
// Publish a message
subs, receivers = bb.Publish("hello")
if subs != 1 {
t.Fatalf("Expected 1 subscriber, got %d", subs)
}
if receivers != 1 {
t.Fatalf("Expected 1 receiver, got %d", receivers)
}
// Verify the message was received
select {
case msg := <-ch:
if msg != "hello" {
t.Fatalf("Expected to receive 'hello', got '%s'", msg)
}
case <-time.After(time.Second):
t.Fatal("Timed out waiting for message")
}
// Test non-blocking behavior with a full channel
// First fill the channel
bb.Publish("fill")
// Now publish again - this should not block but may skip the receiver
subs, receivers = bb.Publish("overflow")
if subs != 1 {
t.Fatalf("Expected 1 subscriber, got %d", subs)
}
_ = receivers // may be 0 if channel is full
// Drain the channel
<-ch
}
func TestBroadcast_PublishWithTimeout(t *testing.T) {
bb := NewBroadcaster[string](10)
// Add a subscriber with a channel
ch, sub := bb.SubscribeByChan(1)
defer sub.Unsubscribe()
// Publish with a timeout
subs, receivers := bb.PublishWithTimeout("hello", 100*time.Millisecond)
if subs != 1 {
t.Fatalf("Expected 1 subscriber, got %d", subs)
}
if receivers != 1 {
t.Fatalf("Expected 1 receiver, got %d", receivers)
}
// Verify the message was received
select {
case msg := <-ch:
if msg != "hello" {
t.Fatalf("Expected to receive 'hello', got '%s'", msg)
}
case <-time.After(time.Second):
t.Fatal("Timed out waiting for message")
}
// Fill the channel
bb.Publish("fill")
// Test timeout behavior with a full channel
start := time.Now()
subs, receivers = bb.PublishWithTimeout("timeout-test", 50*time.Millisecond)
elapsed := time.Since(start)
if subs != 1 {
t.Fatalf("Expected 1 subscriber, got %d", subs)
}
// The receiver count should be 0 if the timeout occurred
if elapsed < 50*time.Millisecond {
t.Fatalf("Expected to wait at least 50ms, only waited %v", elapsed)
}
// Drain the channel
<-ch
}
func TestBroadcast_PublishWithContext(t *testing.T) {
bb := NewBroadcaster[string](10)
// Add a subscriber with a channel
ch, sub := bb.SubscribeByChan(1)
defer sub.Unsubscribe()
// Create a context
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
// Publish with context
subs, receivers, err := bb.PublishWithContext(ctx, "hello")
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if subs != 1 {
t.Fatalf("Expected 1 subscriber, got %d", subs)
}
if receivers != 1 {
t.Fatalf("Expected 1 receiver, got %d", receivers)
}
// Verify the message was received
select {
case msg := <-ch:
if msg != "hello" {
t.Fatalf("Expected to receive 'hello', got '%s'", msg)
}
case <-time.After(time.Second):
t.Fatal("Timed out waiting for message")
}
// Fill the channel
bb.Publish("fill")
// Test context cancellation with a full channel
ctx, cancel = context.WithCancel(context.Background())
// Cancel the context after a short delay
go func() {
time.Sleep(50 * time.Millisecond)
cancel()
}()
start := time.Now()
subs, receivers, err = bb.PublishWithContext(ctx, "context-test")
elapsed := time.Since(start)
if subs != 1 {
t.Fatalf("Expected 1 subscriber, got %d", subs)
}
// Should get a context canceled error
if err == nil {
t.Fatal("Expected context canceled error, got nil")
}
if elapsed < 50*time.Millisecond {
t.Fatalf("Expected to wait at least 50ms, only waited %v", elapsed)
}
// Drain the channel
<-ch
}
func TestBroadcast_Unsubscribe(t *testing.T) {
bb := NewBroadcaster[string](10)
// Add a subscriber
ch, sub := bb.SubscribeByChan(1)
// Publish a message
subs, receivers := bb.Publish("hello")
if subs != 1 {
t.Fatalf("Expected 1 subscriber, got %d", subs)
}
if receivers != 1 {
t.Fatalf("Expected 1 receiver, got %d", receivers)
}
// Verify the message was received
select {
case msg := <-ch:
if msg != "hello" {
t.Fatalf("Expected to receive 'hello', got '%s'", msg)
}
case <-time.After(time.Second):
t.Fatal("Timed out waiting for message")
}
// Unsubscribe
sub.Unsubscribe()
// Publish again
subs, receivers = bb.Publish("after-unsub")
if subs != 0 {
t.Fatalf("Expected 0 subscribers after unsubscribe, got %d", subs)
}
if receivers != 0 {
t.Fatalf("Expected 0 receivers after unsubscribe, got %d", receivers)
}
// Check that the subscriber count is 0
if bb.SubscriberCount() != 0 {
t.Fatalf("Expected SubscriberCount() == 0, got %d", bb.SubscriberCount())
}
}
+16 -1
View File
@@ -115,6 +115,9 @@ func (b *bufferedReadCloser) BufferedAll() ([]byte, error) {
return nil, err
}
}
if err := b.Reset(); err != nil {
return nil, err
}
return b.buffer, nil
case modeSourceFinished:
@@ -131,10 +134,22 @@ func (b *bufferedReadCloser) BufferedAll() ([]byte, error) {
}
}
// Reset resets the buffer to the beginning of the buffer.
// If the original source is partially read, we will finish reading it and fill our buffer
func (b *bufferedReadCloser) Reset() error {
switch b.mode {
case modeSourceReading:
fallthrough
if b.off == 0 {
return nil // nobody has read anything yet
}
err := b.Close()
if err != nil {
return err
}
b.mode = modeBufferReading
b.off = 0
return nil
case modeSourceFinished:
err := b.Close()
if err != nil {
+129
View File
@@ -0,0 +1,129 @@
package dataext
import (
"bytes"
"io"
"testing"
)
type fakeReadCloser struct {
r *bytes.Reader
closed bool
}
func newFakeReadCloser(data []byte) *fakeReadCloser {
return &fakeReadCloser{r: bytes.NewReader(data)}
}
func (f *fakeReadCloser) Read(p []byte) (int, error) {
return f.r.Read(p)
}
func (f *fakeReadCloser) Close() error {
f.closed = true
return nil
}
func TestBufferedReadCloser_ReadAll(t *testing.T) {
data := []byte("hello world")
brc := NewBufferedReadCloser(newFakeReadCloser(data))
buf := make([]byte, 64)
total := 0
for {
n, err := brc.Read(buf[total:])
total += n
if err == io.EOF {
break
}
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
}
if !bytes.Equal(buf[:total], data) {
t.Fatalf("got %q want %q", buf[:total], data)
}
}
func TestBufferedReadCloser_BufferedAllThenRead(t *testing.T) {
data := []byte("foobar baz")
brc := NewBufferedReadCloser(newFakeReadCloser(data))
all, err := brc.BufferedAll()
if err != nil {
t.Fatalf("BufferedAll err: %v", err)
}
if !bytes.Equal(all, data) {
t.Fatalf("BufferedAll got %q want %q", all, data)
}
// after BufferedAll, Reset put us in BufferReading mode - we can read again
out, err := io.ReadAll(brc)
if err != nil {
t.Fatalf("ReadAll err: %v", err)
}
if !bytes.Equal(out, data) {
t.Fatalf("ReadAll got %q want %q", out, data)
}
}
func TestBufferedReadCloser_FullyReadResetReread(t *testing.T) {
data := []byte("abcdefghij")
brc := NewBufferedReadCloser(newFakeReadCloser(data))
out, err := io.ReadAll(brc)
if err != nil {
t.Fatalf("first ReadAll err: %v", err)
}
if !bytes.Equal(out, data) {
t.Fatalf("first read got %q want %q", out, data)
}
if err := brc.Reset(); err != nil {
t.Fatalf("reset err: %v", err)
}
out2, err := io.ReadAll(brc)
if err != nil {
t.Fatalf("second ReadAll err: %v", err)
}
if !bytes.Equal(out2, data) {
t.Fatalf("after reset got %q want %q", out2, data)
}
}
func TestBufferedReadCloser_Close(t *testing.T) {
data := []byte("xyz")
inner := newFakeReadCloser(data)
brc := NewBufferedReadCloser(inner)
if err := brc.Close(); err != nil {
t.Fatalf("close err: %v", err)
}
if !inner.closed {
t.Fatal("inner not closed")
}
// double close should be no-op
if err := brc.Close(); err != nil {
t.Fatalf("second close err: %v", err)
}
}
func TestBufferedReadCloser_ResetWithoutRead(t *testing.T) {
data := []byte("abc")
brc := NewBufferedReadCloser(newFakeReadCloser(data))
if err := brc.Reset(); err != nil {
t.Fatalf("reset err: %v", err)
}
out, err := io.ReadAll(brc)
if err != nil {
t.Fatalf("ReadAll err: %v", err)
}
if !bytes.Equal(out, data) {
t.Fatalf("got %q want %q", out, data)
}
}
+254
View File
@@ -0,0 +1,254 @@
package dataext
import (
"context"
"golang.org/x/sync/semaphore"
"runtime"
"sync"
"sync/atomic"
"time"
"unsafe"
)
// from https://github.com/viney-shih/go-lock/blob/2f19fd8ce335e33e0ab9dccb1ff2ce820c3da332/cas.go
// CASMutex is the struct implementing RWMutex with CAS mechanism.
type CASMutex struct {
state casState
turnstile *semaphore.Weighted
broadcastChan chan struct{}
broadcastMut sync.RWMutex
}
func NewCASMutex() *CASMutex {
return &CASMutex{
state: casStateNoLock,
turnstile: semaphore.NewWeighted(1),
broadcastChan: make(chan struct{}),
}
}
type casState int32
const (
casStateUndefined casState = iota - 2 // -2
casStateWriteLock // -1
casStateNoLock // 0
casStateReadLock // >= 1
)
func (m *CASMutex) getState(n int32) casState {
switch st := casState(n); {
case st == casStateWriteLock:
fallthrough
case st == casStateNoLock:
return st
case st >= casStateReadLock:
return casStateReadLock
default:
// actually, it should not happened.
return casStateUndefined
}
}
func (m *CASMutex) listen() <-chan struct{} {
m.broadcastMut.RLock()
defer m.broadcastMut.RUnlock()
return m.broadcastChan
}
func (m *CASMutex) broadcast() {
newCh := make(chan struct{})
m.broadcastMut.Lock()
ch := m.broadcastChan
m.broadcastChan = newCh
m.broadcastMut.Unlock()
close(ch)
}
func (m *CASMutex) tryLock(ctx context.Context) bool {
for {
broker := m.listen()
if atomic.CompareAndSwapInt32(
(*int32)(unsafe.Pointer(&m.state)),
int32(casStateNoLock),
int32(casStateWriteLock),
) {
return true
}
if ctx == nil {
return false
}
select {
case <-ctx.Done():
// timeout or cancellation
return false
case <-broker:
// waiting for signal triggered by m.broadcast() and trying again.
}
}
}
// TryLockWithContext attempts to acquire the lock, blocking until resources
// are available or ctx is done (timeout or cancellation).
func (m *CASMutex) TryLockWithContext(ctx context.Context) bool {
if err := m.turnstile.Acquire(ctx, 1); err != nil {
// Acquire failed due to timeout or cancellation
return false
}
defer m.turnstile.Release(1)
return m.tryLock(ctx)
}
// Lock acquires the lock.
// If it is currently held by others, Lock will wait until it has a chance to acquire it.
func (m *CASMutex) Lock() {
ctx := context.Background()
m.TryLockWithContext(ctx)
}
// TryLock attempts to acquire the lock without blocking.
// Return false if someone is holding it now.
func (m *CASMutex) TryLock() bool {
if !m.turnstile.TryAcquire(1) {
return false
}
defer m.turnstile.Release(1)
return m.tryLock(nil)
}
// TryLockWithTimeout attempts to acquire the lock within a period of time.
// Return false if spending time is more than duration and no chance to acquire it.
func (m *CASMutex) TryLockWithTimeout(duration time.Duration) bool {
ctx, cancel := context.WithTimeout(context.Background(), duration)
defer cancel()
return m.TryLockWithContext(ctx)
}
// Unlock releases the lock.
func (m *CASMutex) Unlock() {
if ok := atomic.CompareAndSwapInt32(
(*int32)(unsafe.Pointer(&m.state)),
int32(casStateWriteLock),
int32(casStateNoLock),
); !ok {
panic("Unlock failed")
}
m.broadcast()
}
func (m *CASMutex) rTryLock(ctx context.Context) bool {
for {
broker := m.listen()
n := atomic.LoadInt32((*int32)(unsafe.Pointer(&m.state)))
st := m.getState(n)
switch st {
case casStateNoLock, casStateReadLock:
if atomic.CompareAndSwapInt32((*int32)(unsafe.Pointer(&m.state)), n, n+1) {
return true
}
}
if ctx == nil {
return false
}
select {
case <-ctx.Done():
// timeout or cancellation
return false
default:
switch st {
// read-lock failed due to concurrence issue, try again immediately
case casStateNoLock, casStateReadLock:
runtime.Gosched() // allow other goroutines to do stuff.
continue
}
}
select {
case <-ctx.Done():
// timeout or cancellation
return false
case <-broker:
// waiting for signal triggered by m.broadcast() and trying again.
}
}
}
// RTryLockWithContext attempts to acquire the read lock, blocking until resources
// are available or ctx is done (timeout or cancellation).
func (m *CASMutex) RTryLockWithContext(ctx context.Context) bool {
if err := m.turnstile.Acquire(ctx, 1); err != nil {
// Acquire failed due to timeout or cancellation
return false
}
m.turnstile.Release(1)
return m.rTryLock(ctx)
}
// RLock acquires the read lock.
// If it is currently held by others writing, RLock will wait until it has a chance to acquire it.
func (m *CASMutex) RLock() {
ctx := context.Background()
m.RTryLockWithContext(ctx)
}
// RTryLock attempts to acquire the read lock without blocking.
// Return false if someone is writing it now.
func (m *CASMutex) RTryLock() bool {
if !m.turnstile.TryAcquire(1) {
return false
}
m.turnstile.Release(1)
return m.rTryLock(nil)
}
// RTryLockWithTimeout attempts to acquire the read lock within a period of time.
// Return false if spending time is more than duration and no chance to acquire it.
func (m *CASMutex) RTryLockWithTimeout(duration time.Duration) bool {
ctx, cancel := context.WithTimeout(context.Background(), duration)
defer cancel()
return m.RTryLockWithContext(ctx)
}
// RUnlock releases the read lock.
func (m *CASMutex) RUnlock() {
n := atomic.AddInt32((*int32)(unsafe.Pointer(&m.state)), -1)
switch m.getState(n) {
case casStateUndefined, casStateWriteLock:
panic("RUnlock failed")
case casStateNoLock:
m.broadcast()
}
}
// RLocker returns a Locker interface that implements the Lock and Unlock methods
// by calling CASMutex.RLock and CASMutex.RUnlock.
func (m *CASMutex) RLocker() sync.Locker {
return (*rlocker)(m)
}
type rlocker CASMutex
func (r *rlocker) Lock() { (*CASMutex)(r).RLock() }
func (r *rlocker) Unlock() { (*CASMutex)(r).RUnlock() }
+122
View File
@@ -0,0 +1,122 @@
package dataext
import (
"context"
"sync"
"sync/atomic"
"testing"
"time"
)
func TestCASMutex_LockUnlock(t *testing.T) {
m := NewCASMutex()
m.Lock()
m.Unlock()
}
func TestCASMutex_TryLock(t *testing.T) {
m := NewCASMutex()
if !m.TryLock() {
t.Fatal("TryLock should succeed on fresh mutex")
}
if m.TryLock() {
t.Fatal("TryLock should fail when already locked")
}
m.Unlock()
if !m.TryLock() {
t.Fatal("TryLock should succeed after Unlock")
}
m.Unlock()
}
func TestCASMutex_TryLockWithTimeout(t *testing.T) {
m := NewCASMutex()
m.Lock()
start := time.Now()
if m.TryLockWithTimeout(20 * time.Millisecond) {
t.Fatal("TryLockWithTimeout should fail when locked")
}
if time.Since(start) < 15*time.Millisecond {
t.Fatal("TryLockWithTimeout returned too quickly")
}
m.Unlock()
if !m.TryLockWithTimeout(50 * time.Millisecond) {
t.Fatal("TryLockWithTimeout should succeed when unlocked")
}
m.Unlock()
}
func TestCASMutex_TryLockWithContext_Cancel(t *testing.T) {
m := NewCASMutex()
m.Lock()
defer m.Unlock()
ctx, cancel := context.WithCancel(context.Background())
go func() {
time.Sleep(10 * time.Millisecond)
cancel()
}()
if m.TryLockWithContext(ctx) {
t.Fatal("expected lock to fail after cancel")
}
}
func TestCASMutex_RLockMultiple(t *testing.T) {
m := NewCASMutex()
if !m.RTryLock() {
t.Fatal("RTryLock should succeed")
}
if !m.RTryLock() {
t.Fatal("Second RTryLock should succeed")
}
if m.TryLock() {
t.Fatal("Write TryLock should fail with read locks held")
}
m.RUnlock()
m.RUnlock()
if !m.TryLock() {
t.Fatal("Write TryLock should succeed after read unlocks")
}
m.Unlock()
}
func TestCASMutex_RLocker(t *testing.T) {
m := NewCASMutex()
rl := m.RLocker()
rl.Lock()
rl.Unlock()
}
func TestCASMutex_Concurrent(t *testing.T) {
m := NewCASMutex()
var counter int64
const n = 50
var wg sync.WaitGroup
wg.Add(n)
for i := 0; i < n; i++ {
go func() {
defer wg.Done()
m.Lock()
atomic.AddInt64(&counter, 1)
m.Unlock()
}()
}
wg.Wait()
if atomic.LoadInt64(&counter) != n {
t.Fatalf("counter=%d want %d", counter, n)
}
}
func TestCASMutex_RTryLockWithTimeout(t *testing.T) {
m := NewCASMutex()
m.Lock()
if m.RTryLockWithTimeout(20 * time.Millisecond) {
t.Fatal("RTryLockWithTimeout should fail when write-locked")
}
m.Unlock()
if !m.RTryLockWithTimeout(20 * time.Millisecond) {
t.Fatal("RTryLockWithTimeout should succeed when free")
}
m.RUnlock()
}
+199
View File
@@ -0,0 +1,199 @@
package dataext
import (
"context"
"sync"
"time"
"git.blackforestbytes.com/BlackForestBytes/goext/langext"
"git.blackforestbytes.com/BlackForestBytes/goext/syncext"
)
// DelayedCombiningInvoker is a utility to combine multiple consecutive requests into a single execution
//
// Requests are made with Request(), and consecutive requests are combined during the `delay` period.
//
// Can be used, e.g., for search-controls, where we want to init the search when teh user stops typing
// Or generally to queue an execution once a burst of requests is over.
type DelayedCombiningInvoker struct {
syncLock sync.Mutex
triggerChan chan bool
cancelChan chan bool
execNowChan chan bool
action func()
delay time.Duration
maxDelay time.Duration
executorRunning *syncext.AtomicBool
pendingRequests *syncext.Atomic[int]
lastRequestTime time.Time
initialRequestTime time.Time
onExecutionStart []func(immediately bool) // listener ( actual execution of action starts )
onExecutionDone []func() // listener ( actual execution of action finished )
onRequest []func(pending int, initial bool) // listener ( a request came in, waiting for execution )
}
func NewDelayedCombiningInvoker(action func(), delay time.Duration, maxDelay time.Duration) *DelayedCombiningInvoker {
return &DelayedCombiningInvoker{
action: action,
delay: delay,
maxDelay: maxDelay,
executorRunning: syncext.NewAtomicBool(false),
pendingRequests: syncext.NewAtomic[int](0),
triggerChan: make(chan bool),
cancelChan: make(chan bool, 1),
execNowChan: make(chan bool, 1),
lastRequestTime: time.Now(),
initialRequestTime: time.Now(),
onExecutionStart: make([]func(bool), 0),
onExecutionDone: make([]func(), 0),
onRequest: make([]func(int, bool), 0),
}
}
func (d *DelayedCombiningInvoker) Request() {
now := time.Now()
d.syncLock.Lock()
defer d.syncLock.Unlock()
if d.executorRunning.Get() {
d.lastRequestTime = now
d.pendingRequests.Update(func(v int) int { return v + 1 })
for _, fn := range d.onRequest {
_ = langext.RunPanicSafe(func() { fn(d.pendingRequests.Get(), true) })
}
d.triggerChan <- true
} else {
d.initialRequestTime = now
d.lastRequestTime = now
d.executorRunning.Set(true)
d.pendingRequests.Set(1)
syncext.ReadNonBlocking(d.triggerChan) // clear the channel
syncext.ReadNonBlocking(d.cancelChan) // clear the channel
syncext.ReadNonBlocking(d.execNowChan) // clear the channel
for _, fn := range d.onRequest {
_ = langext.RunPanicSafe(func() { fn(d.pendingRequests.Get(), false) })
}
go d.run()
}
}
func (d *DelayedCombiningInvoker) run() {
needsExecutorRunningCleanup := true
defer func() {
if needsExecutorRunningCleanup {
d.syncLock.Lock()
d.executorRunning.Set(false)
d.syncLock.Unlock()
}
}()
for {
d.syncLock.Lock()
timeOut := max(min(d.maxDelay-time.Since(d.initialRequestTime), d.delay-time.Since(d.lastRequestTime)), 0)
d.syncLock.Unlock()
immediately := false
select {
case <-d.execNowChan:
// run immediately
immediately = true
break
case <-d.triggerChan:
// external trigger - needs to re-evaluate
break
case <-d.cancelChan:
// cancel
return
case <-time.After(timeOut):
// time elapsed - check for execution
break
}
d.syncLock.Lock()
execute := immediately || time.Since(d.lastRequestTime) >= d.delay || time.Since(d.initialRequestTime) >= d.maxDelay
if !execute {
d.syncLock.Unlock()
continue
}
d.pendingRequests.Set(0)
for _, fn := range d.onExecutionStart {
_ = langext.RunPanicSafe(func() { fn(immediately) })
}
// =================================================
_ = langext.RunPanicSafe(d.action)
// =================================================
d.executorRunning.Set(false) // ensure HasPendingRequests returns fals ein onExecutionDone listener
needsExecutorRunningCleanup = false
for _, fn := range d.onExecutionDone {
_ = langext.RunPanicSafe(fn)
}
d.syncLock.Unlock()
return
}
}
func (d *DelayedCombiningInvoker) CancelPendingRequests() {
d.syncLock.Lock()
defer d.syncLock.Unlock()
syncext.WriteNonBlocking(d.cancelChan, true)
}
func (d *DelayedCombiningInvoker) HasPendingRequests() bool {
return d.executorRunning.Get()
}
func (d *DelayedCombiningInvoker) CountPendingRequests() int {
return d.pendingRequests.Get()
}
func (d *DelayedCombiningInvoker) ExecuteNow() bool {
d.syncLock.Lock()
defer d.syncLock.Unlock()
if d.executorRunning.Get() {
syncext.WriteNonBlocking(d.execNowChan, true)
return true
} else {
return false
}
}
func (d *DelayedCombiningInvoker) WaitForCompletion(ctx context.Context) error {
return d.executorRunning.WaitWithContext(ctx, false)
}
func (d *DelayedCombiningInvoker) RegisterOnExecutionStart(fn func(immediately bool)) {
d.syncLock.Lock()
defer d.syncLock.Unlock()
d.onExecutionStart = append(d.onExecutionStart, fn)
}
func (d *DelayedCombiningInvoker) RegisterOnExecutionDone(fn func()) {
d.syncLock.Lock()
defer d.syncLock.Unlock()
d.onExecutionDone = append(d.onExecutionDone, fn)
}
func (d *DelayedCombiningInvoker) RegisterOnRequest(fn func(pending int, initial bool)) {
d.syncLock.Lock()
defer d.syncLock.Unlock()
d.onRequest = append(d.onRequest, fn)
}
+182
View File
@@ -0,0 +1,182 @@
package dataext
import (
"sync/atomic"
"testing"
"time"
)
func waitForCalls(t *testing.T, calls *int64, want int64, max time.Duration) {
t.Helper()
deadline := time.Now().Add(max)
for time.Now().Before(deadline) {
if atomic.LoadInt64(calls) >= want {
return
}
time.Sleep(5 * time.Millisecond)
}
}
func TestDelayedCombiningInvoker_SingleRequest(t *testing.T) {
var calls int64
d := NewDelayedCombiningInvoker(func() {
atomic.AddInt64(&calls, 1)
}, 20*time.Millisecond, 200*time.Millisecond)
d.Request()
waitForCalls(t, &calls, 1, 2*time.Second)
if c := atomic.LoadInt64(&calls); c != 1 {
t.Fatalf("calls=%d want 1", c)
}
}
func TestDelayedCombiningInvoker_TwoRequestsCombine(t *testing.T) {
var calls int64
d := NewDelayedCombiningInvoker(func() {
atomic.AddInt64(&calls, 1)
}, 50*time.Millisecond, 1*time.Second)
d.Request()
time.Sleep(10 * time.Millisecond)
d.Request()
waitForCalls(t, &calls, 1, 2*time.Second)
if c := atomic.LoadInt64(&calls); c != 1 {
t.Fatalf("calls=%d want 1 (should be combined)", c)
}
}
func TestDelayedCombiningInvoker_SequentialRuns(t *testing.T) {
var calls int64
d := NewDelayedCombiningInvoker(func() {
atomic.AddInt64(&calls, 1)
}, 20*time.Millisecond, 200*time.Millisecond)
d.Request()
waitForCalls(t, &calls, 1, 2*time.Second)
if c := atomic.LoadInt64(&calls); c != 1 {
t.Fatalf("after first wait calls=%d want 1", c)
}
// allow executorRunning to clear
time.Sleep(50 * time.Millisecond)
d.Request()
waitForCalls(t, &calls, 2, 2*time.Second)
if c := atomic.LoadInt64(&calls); c != 2 {
t.Fatalf("calls=%d want 2", c)
}
}
func TestDelayedCombiningInvoker_ExecuteNow(t *testing.T) {
var calls int64
d := NewDelayedCombiningInvoker(func() {
atomic.AddInt64(&calls, 1)
}, 5*time.Second, 30*time.Second)
d.Request()
if !d.HasPendingRequests() {
t.Fatal("should have pending requests")
}
if !d.ExecuteNow() {
t.Fatal("ExecuteNow should return true when running")
}
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if atomic.LoadInt64(&calls) >= 1 {
break
}
time.Sleep(10 * time.Millisecond)
}
if c := atomic.LoadInt64(&calls); c != 1 {
t.Fatalf("calls=%d want 1 (ExecuteNow should fire well before delay)", c)
}
// allow internal state cleanup
for i := 0; i < 100; i++ {
if !d.HasPendingRequests() {
break
}
time.Sleep(10 * time.Millisecond)
}
if d.ExecuteNow() {
t.Fatal("ExecuteNow should return false when no pending")
}
}
func TestDelayedCombiningInvoker_Cancel(t *testing.T) {
var calls int64
d := NewDelayedCombiningInvoker(func() {
atomic.AddInt64(&calls, 1)
}, 500*time.Millisecond, 5*time.Second)
d.Request()
d.CancelPendingRequests()
time.Sleep(200 * time.Millisecond)
if c := atomic.LoadInt64(&calls); c != 0 {
t.Fatalf("calls=%d want 0 after cancel", c)
}
}
func TestDelayedCombiningInvoker_HasAndCountPending(t *testing.T) {
d := NewDelayedCombiningInvoker(func() {
// no-op
}, 500*time.Millisecond, 5*time.Second)
if d.HasPendingRequests() {
t.Fatal("should not have pending before any Request")
}
if d.CountPendingRequests() != 0 {
t.Fatalf("count=%d want 0", d.CountPendingRequests())
}
d.Request()
if !d.HasPendingRequests() {
t.Fatal("should have pending")
}
if d.CountPendingRequests() < 1 {
t.Fatalf("count=%d want >=1", d.CountPendingRequests())
}
d.CancelPendingRequests()
}
func TestDelayedCombiningInvoker_Listeners(t *testing.T) {
var (
startCount int64
doneCount int64
requestCount int64
)
d := NewDelayedCombiningInvoker(func() {
// no-op
}, 20*time.Millisecond, 200*time.Millisecond)
d.RegisterOnExecutionStart(func(immediately bool) {
atomic.AddInt64(&startCount, 1)
})
d.RegisterOnExecutionDone(func() {
atomic.AddInt64(&doneCount, 1)
})
d.RegisterOnRequest(func(pending int, initial bool) {
atomic.AddInt64(&requestCount, 1)
})
d.Request()
waitForCalls(t, &doneCount, 1, 2*time.Second)
if atomic.LoadInt64(&startCount) != 1 {
t.Fatalf("startCount=%d want 1", startCount)
}
if atomic.LoadInt64(&doneCount) != 1 {
t.Fatalf("doneCount=%d want 1", doneCount)
}
if atomic.LoadInt64(&requestCount) != 1 {
t.Fatalf("requestCount=%d want 1", requestCount)
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
package dataext
import (
"gogs.mikescher.com/BlackForestBytes/goext/langext"
"git.blackforestbytes.com/BlackForestBytes/goext/langext"
"math/rand"
"strconv"
"testing"
+1 -1
View File
@@ -14,7 +14,7 @@ func ObjectMerge[T1 any, T2 any](base T1, override T2) T1 {
fieldBase := reflBase.Field(i)
fieldOvrd := reflOvrd.Field(i)
if fieldBase.Kind() != reflect.Ptr || fieldOvrd.Kind() != reflect.Ptr {
if fieldBase.Kind() != reflect.Pointer || fieldOvrd.Kind() != reflect.Pointer {
continue
}
+5 -6
View File
@@ -1,8 +1,7 @@
package dataext
import (
"gogs.mikescher.com/BlackForestBytes/goext/langext"
"gogs.mikescher.com/BlackForestBytes/goext/tst"
"git.blackforestbytes.com/BlackForestBytes/goext/tst"
"testing"
)
@@ -26,17 +25,17 @@ func TestObjectMerge(t *testing.T) {
valueA := A{
Field1: nil,
Field2: langext.Ptr("99"),
Field3: langext.Ptr(12.2),
Field2: new("99"),
Field3: new(12.2),
Field4: nil,
OnlyA: 1,
DiffType: 2,
}
valueB := B{
Field1: langext.Ptr(12),
Field1: new(12),
Field2: nil,
Field3: langext.Ptr(13.2),
Field3: new(13.2),
Field4: nil,
OnlyB: 1,
DiffType: "X",
+105
View File
@@ -0,0 +1,105 @@
package dataext
import (
"context"
"sync"
"time"
)
// MultiMutex is a simple map[key -> mutex]
type MultiMutex[TKey comparable] struct {
mutextMap *SyncMap[TKey, *CASMutex]
}
func NewMultiMutex[TKey comparable]() *MultiMutex[TKey] {
return &MultiMutex[TKey]{
mutextMap: NewSyncMap[TKey, *CASMutex](),
}
}
// TryLockWithContext attempts to acquire the lock, blocking until resources
// are available or ctx is done (timeout or cancellation).
func (mm *MultiMutex[TKey]) TryLockWithContext(ctx context.Context, key TKey) bool {
lck := mm.mutextMap.GetAndSetIfNotContainsFunc(key, NewCASMutex)
return lck.TryLockWithContext(ctx)
}
// Lock acquires the lock.
// If it is currently held by others, Lock will wait until it has a chance to acquire it.
func (mm *MultiMutex[TKey]) Lock(key TKey) {
lck := mm.mutextMap.GetAndSetIfNotContainsFunc(key, NewCASMutex)
lck.Lock()
}
// TryLock attempts to acquire the lock without blocking.
// Return false if someone is holding it now.
func (mm *MultiMutex[TKey]) TryLock(key TKey) bool {
lck := mm.mutextMap.GetAndSetIfNotContainsFunc(key, NewCASMutex)
return lck.TryLock()
}
// TryLockWithTimeout attempts to acquire the lock within a period of time.
// Return false if spending time is more than duration and no chance to acquire it.
func (mm *MultiMutex[TKey]) TryLockWithTimeout(key TKey, duration time.Duration) bool {
lck := mm.mutextMap.GetAndSetIfNotContainsFunc(key, NewCASMutex)
return lck.TryLockWithTimeout(duration)
}
// Unlock releases the lock.
func (mm *MultiMutex[TKey]) Unlock(key TKey) {
lck := mm.mutextMap.GetAndSetIfNotContainsFunc(key, NewCASMutex)
lck.Unlock()
}
// RTryLockWithContext attempts to acquire the read lock, blocking until resources
// are available or ctx is done (timeout or cancellation).
func (mm *MultiMutex[TKey]) RTryLockWithContext(ctx context.Context, key TKey) bool {
lck := mm.mutextMap.GetAndSetIfNotContainsFunc(key, NewCASMutex)
return lck.RTryLockWithContext(ctx)
}
// RLock acquires the read lock.
// If it is currently held by others writing, RLock will wait until it has a chance to acquire it.
func (mm *MultiMutex[TKey]) RLock(key TKey) {
lck := mm.mutextMap.GetAndSetIfNotContainsFunc(key, NewCASMutex)
lck.RLock()
}
// RTryLock attempts to acquire the read lock without blocking.
// Return false if someone is writing it now.
func (mm *MultiMutex[TKey]) RTryLock(key TKey) bool {
lck := mm.mutextMap.GetAndSetIfNotContainsFunc(key, NewCASMutex)
return lck.RTryLock()
}
// RTryLockWithTimeout attempts to acquire the read lock within a period of time.
// Return false if spending time is more than duration and no chance to acquire it.
func (mm *MultiMutex[TKey]) RTryLockWithTimeout(duration time.Duration, key TKey) bool {
lck := mm.mutextMap.GetAndSetIfNotContainsFunc(key, NewCASMutex)
return lck.RTryLockWithTimeout(duration)
}
// RUnlock releases the read lock.
func (mm *MultiMutex[TKey]) RUnlock(key TKey) {
lck := mm.mutextMap.GetAndSetIfNotContainsFunc(key, NewCASMutex)
lck.RUnlock()
}
// RLocker returns a Locker interface that implements the Lock and Unlock methods
// by calling CASMutex.RLock and CASMutex.RUnlock.
func (mm *MultiMutex[TKey]) RLocker(key TKey) sync.Locker {
lck := mm.mutextMap.GetAndSetIfNotContainsFunc(key, NewCASMutex)
return lck.RLocker()
}
// Get returns a Locker interface
func (mm *MultiMutex[TKey]) Get(key TKey) sync.Locker {
lck := mm.mutextMap.GetAndSetIfNotContainsFunc(key, NewCASMutex)
return lck
}
// GetCAS returns the underlying CASMutex
func (mm *MultiMutex[TKey]) GetCAS(key TKey) *CASMutex {
lck := mm.mutextMap.GetAndSetIfNotContainsFunc(key, NewCASMutex)
return lck
}
+89
View File
@@ -0,0 +1,89 @@
package dataext
import (
"context"
"testing"
"time"
)
func TestMultiMutex_LockDifferentKeys(t *testing.T) {
mm := NewMultiMutex[string]()
mm.Lock("a")
mm.Lock("b")
mm.Unlock("a")
mm.Unlock("b")
}
func TestMultiMutex_TryLockSameKey(t *testing.T) {
mm := NewMultiMutex[string]()
if !mm.TryLock("k") {
t.Fatal("TryLock should succeed first time")
}
if mm.TryLock("k") {
t.Fatal("TryLock should fail second time")
}
mm.Unlock("k")
if !mm.TryLock("k") {
t.Fatal("TryLock should succeed after unlock")
}
mm.Unlock("k")
}
func TestMultiMutex_TryLockDifferentKeys(t *testing.T) {
mm := NewMultiMutex[int]()
if !mm.TryLock(1) {
t.Fatal("TryLock(1) failed")
}
if !mm.TryLock(2) {
t.Fatal("TryLock(2) failed - different keys should be independent")
}
mm.Unlock(1)
mm.Unlock(2)
}
func TestMultiMutex_RLockMultiple(t *testing.T) {
mm := NewMultiMutex[string]()
if !mm.RTryLock("k") {
t.Fatal("first RTryLock failed")
}
if !mm.RTryLock("k") {
t.Fatal("second RTryLock failed")
}
mm.RUnlock("k")
mm.RUnlock("k")
}
func TestMultiMutex_TryLockWithTimeout(t *testing.T) {
mm := NewMultiMutex[string]()
mm.Lock("k")
if mm.TryLockWithTimeout("k", 10*time.Millisecond) {
t.Fatal("expected timeout failure")
}
mm.Unlock("k")
}
func TestMultiMutex_TryLockWithContext(t *testing.T) {
mm := NewMultiMutex[string]()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond)
defer cancel()
if !mm.TryLockWithContext(ctx, "k") {
t.Fatal("TryLockWithContext should succeed on free key")
}
mm.Unlock("k")
}
func TestMultiMutex_GetAndGetCAS(t *testing.T) {
mm := NewMultiMutex[string]()
l := mm.Get("a")
if l == nil {
t.Fatal("Get returned nil")
}
cas := mm.GetCAS("a")
if cas == nil {
t.Fatal("GetCAS returned nil")
}
rl := mm.RLocker("a")
if rl == nil {
t.Fatal("RLocker returned nil")
}
}
+53
View File
@@ -0,0 +1,53 @@
package dataext
import "sync"
type MutexSet[T comparable] struct {
master sync.RWMutex
locks map[T]*sync.RWMutex
}
func NewMutexSet[T comparable]() *MutexSet[T] {
return &MutexSet[T]{
master: sync.RWMutex{},
locks: make(map[T]*sync.RWMutex),
}
}
func (ms *MutexSet[T]) get(key T) *sync.RWMutex {
ms.master.RLock()
if v, ok := ms.locks[key]; ok {
ms.master.RUnlock()
return v
}
ms.master.RUnlock()
// ---------
ms.master.Lock()
defer ms.master.Unlock()
if v, ok := ms.locks[key]; ok {
return v
}
m := &sync.RWMutex{}
ms.locks[key] = m
return m
}
func (ms *MutexSet[T]) Lock(key T) {
ms.get(key).Lock()
}
func (ms *MutexSet[T]) Unlock(key T) {
ms.get(key).Unlock()
}
func (ms *MutexSet[T]) RLock(key T) {
ms.get(key).RLock()
}
func (ms *MutexSet[T]) RUnlock(key T) {
ms.get(key).RUnlock()
}
+51
View File
@@ -0,0 +1,51 @@
package dataext
import (
"sync"
"sync/atomic"
"testing"
)
func TestMutexSet_BasicLockUnlock(t *testing.T) {
ms := NewMutexSet[string]()
ms.Lock("a")
ms.Unlock("a")
ms.RLock("b")
ms.RUnlock("b")
}
func TestMutexSet_DifferentKeysIndependent(t *testing.T) {
ms := NewMutexSet[int]()
ms.Lock(1)
ms.Lock(2)
ms.Unlock(1)
ms.Unlock(2)
}
func TestMutexSet_SameKeyMutuallyExclusive(t *testing.T) {
ms := NewMutexSet[string]()
var counter int64
const n = 50
var wg sync.WaitGroup
wg.Add(n)
for i := 0; i < n; i++ {
go func() {
defer wg.Done()
ms.Lock("shared")
atomic.AddInt64(&counter, 1)
ms.Unlock("shared")
}()
}
wg.Wait()
if atomic.LoadInt64(&counter) != n {
t.Fatalf("got %d want %d", counter, n)
}
}
func TestMutexSet_RLockMultiple(t *testing.T) {
ms := NewMutexSet[string]()
ms.RLock("k")
ms.RLock("k")
ms.RUnlock("k")
ms.RUnlock("k")
}
+83
View File
@@ -0,0 +1,83 @@
package dataext
import (
"encoding/json"
"errors"
"git.blackforestbytes.com/BlackForestBytes/goext/langext"
)
type JsonOpt[T any] struct {
isSet bool
value T
}
func NewJsonOpt[T any](v T) JsonOpt[T] {
return JsonOpt[T]{isSet: true, value: v}
}
func EmptyJsonOpt[T any]() JsonOpt[T] {
return JsonOpt[T]{isSet: false}
}
// MarshalJSON returns m as the JSON encoding of m.
func (m JsonOpt[T]) MarshalJSON() ([]byte, error) {
if !m.isSet {
return []byte("null"), nil // actually this would be undefined - but undefined is not valid JSON
}
return json.Marshal(m.value)
}
// UnmarshalJSON sets *m to a copy of data.
func (m *JsonOpt[T]) UnmarshalJSON(data []byte) error {
if m == nil {
return errors.New("JsonOpt: UnmarshalJSON on nil pointer")
}
m.isSet = true
return json.Unmarshal(data, &m.value)
}
func (m JsonOpt[T]) IsSet() bool {
return m.isSet
}
func (m JsonOpt[T]) IsUnset() bool {
return !m.isSet
}
func (m JsonOpt[T]) Value() (T, bool) {
if !m.isSet {
return *new(T), false
}
return m.value, true
}
func (m JsonOpt[T]) ValueOrNil() *T {
if !m.isSet {
return nil
}
return &m.value
}
func (m JsonOpt[T]) ValueDblPtrOrNil() **T {
if !m.isSet {
return nil
}
return langext.DblPtr(m.value)
}
func (m JsonOpt[T]) MustValue() T {
if !m.isSet {
panic("value not set")
}
return m.value
}
func (m JsonOpt[T]) IfSet(fn func(v T)) bool {
if !m.isSet {
return false
}
fn(m.value)
return true
}
+142
View File
@@ -0,0 +1,142 @@
package dataext
import (
"encoding/json"
"testing"
)
func TestJsonOpt_NewAndEmpty(t *testing.T) {
o := NewJsonOpt[int](42)
if !o.IsSet() {
t.Fatal("expected IsSet=true")
}
if o.IsUnset() {
t.Fatal("expected IsUnset=false")
}
e := EmptyJsonOpt[int]()
if e.IsSet() {
t.Fatal("expected IsSet=false")
}
if !e.IsUnset() {
t.Fatal("expected IsUnset=true")
}
}
func TestJsonOpt_Value(t *testing.T) {
o := NewJsonOpt[string]("hello")
v, ok := o.Value()
if !ok || v != "hello" {
t.Fatalf("got (%q,%v)", v, ok)
}
e := EmptyJsonOpt[string]()
v, ok = e.Value()
if ok || v != "" {
t.Fatalf("empty got (%q,%v)", v, ok)
}
}
func TestJsonOpt_ValueOrNil(t *testing.T) {
o := NewJsonOpt[int](7)
p := o.ValueOrNil()
if p == nil || *p != 7 {
t.Fatalf("expected ptr to 7")
}
e := EmptyJsonOpt[int]()
if e.ValueOrNil() != nil {
t.Fatal("expected nil")
}
}
func TestJsonOpt_ValueDblPtrOrNil(t *testing.T) {
o := NewJsonOpt[int](7)
p := o.ValueDblPtrOrNil()
if p == nil || *p == nil || **p != 7 {
t.Fatalf("expected double ptr to 7")
}
e := EmptyJsonOpt[int]()
if e.ValueDblPtrOrNil() != nil {
t.Fatal("expected nil")
}
}
func TestJsonOpt_MustValue(t *testing.T) {
o := NewJsonOpt[int](9)
if o.MustValue() != 9 {
t.Fatal("MustValue wrong")
}
defer func() {
if recover() == nil {
t.Fatal("expected panic")
}
}()
EmptyJsonOpt[int]().MustValue()
}
func TestJsonOpt_IfSet(t *testing.T) {
called := false
NewJsonOpt[int](1).IfSet(func(v int) {
called = true
if v != 1 {
t.Fatalf("v=%d", v)
}
})
if !called {
t.Fatal("IfSet did not invoke fn")
}
called = false
EmptyJsonOpt[int]().IfSet(func(v int) { called = true })
if called {
t.Fatal("IfSet invoked fn on empty")
}
}
func TestJsonOpt_MarshalJSON(t *testing.T) {
o := NewJsonOpt[int](5)
b, err := json.Marshal(o)
if err != nil {
t.Fatal(err)
}
if string(b) != "5" {
t.Fatalf("got %s", b)
}
e := EmptyJsonOpt[int]()
b, err = json.Marshal(e)
if err != nil {
t.Fatal(err)
}
if string(b) != "null" {
t.Fatalf("got %s", b)
}
}
func TestJsonOpt_UnmarshalJSON(t *testing.T) {
var o JsonOpt[int]
if err := json.Unmarshal([]byte("42"), &o); err != nil {
t.Fatal(err)
}
if !o.IsSet() {
t.Fatal("should be set")
}
if v, _ := o.Value(); v != 42 {
t.Fatalf("got %d", v)
}
}
func TestJsonOpt_StructWithJsonOpt(t *testing.T) {
type S struct {
A JsonOpt[int] `json:"a"`
B JsonOpt[string] `json:"b"`
}
s := S{A: NewJsonOpt[int](1), B: EmptyJsonOpt[string]()}
b, err := json.Marshal(s)
if err != nil {
t.Fatal(err)
}
if string(b) != `{"a":1,"b":null}` {
t.Fatalf("got %s", b)
}
}
+267
View File
@@ -0,0 +1,267 @@
package dataext
import (
"context"
"iter"
"sync"
"time"
"git.blackforestbytes.com/BlackForestBytes/goext/langext"
"git.blackforestbytes.com/BlackForestBytes/goext/syncext"
"github.com/rs/xid"
)
// PubSub is a simple Pub/Sub Broker
// Clients can subscribe to a namespace and receive published messages on this namespace
// Messages are broadcast to all subscribers
type PubSub[TNamespace comparable, TData any] struct {
masterLock *sync.Mutex
subscriptions map[TNamespace][]*pubSubSubscription[TNamespace, TData]
}
type PubSubSubscription interface {
Unsubscribe()
}
type pubSubSubscription[TNamespace comparable, TData any] struct {
ID string
parent *PubSub[TNamespace, TData]
namespace TNamespace
subLock *sync.Mutex
Func func(TData)
Chan chan TData
UnsubChan chan bool
}
func (p *pubSubSubscription[TNamespace, TData]) Unsubscribe() {
p.parent.unsubscribe(p)
}
func NewPubSub[TNamespace comparable, TData any](capacity int) *PubSub[TNamespace, TData] {
return &PubSub[TNamespace, TData]{
masterLock: &sync.Mutex{},
subscriptions: make(map[TNamespace][]*pubSubSubscription[TNamespace, TData], capacity),
}
}
func (ps *PubSub[TNamespace, TData]) Namespaces() []TNamespace {
ps.masterLock.Lock()
defer ps.masterLock.Unlock()
return langext.MapKeyArr(ps.subscriptions)
}
func (ps *PubSub[TNamespace, TData]) SubscriberCount(ns TNamespace) int {
ps.masterLock.Lock()
defer ps.masterLock.Unlock()
return len(ps.subscriptions[ns])
}
// Publish sends `data` to all subscriber
// But unbuffered - if one is currently not listening, we skip (the actualReceiver < subscriber)
func (ps *PubSub[TNamespace, TData]) Publish(ns TNamespace, data TData) (subscriber int, actualReceiver int) {
ps.masterLock.Lock()
subs := langext.ArrCopy(ps.subscriptions[ns])
ps.masterLock.Unlock()
subscriber = len(subs)
actualReceiver = 0
for _, sub := range subs {
func() {
sub.subLock.Lock()
defer sub.subLock.Unlock()
if sub.Func != nil {
go func() { sub.Func(data) }()
actualReceiver++
} else if sub.Chan != nil {
msgSent := syncext.WriteNonBlocking(sub.Chan, data)
if msgSent {
actualReceiver++
}
}
}()
}
return subscriber, actualReceiver
}
// PublishWithContext sends `data` to all subscriber
// buffered - if one is currently not listening, we wait (but error out when the context runs out)
func (ps *PubSub[TNamespace, TData]) PublishWithContext(ctx context.Context, ns TNamespace, data TData) (subscriber int, actualReceiver int, err error) {
ps.masterLock.Lock()
subs := langext.ArrCopy(ps.subscriptions[ns])
ps.masterLock.Unlock()
subscriber = len(subs)
actualReceiver = 0
for _, sub := range subs {
err := func() error {
sub.subLock.Lock()
defer sub.subLock.Unlock()
if err := ctx.Err(); err != nil {
return err
}
if sub.Func != nil {
go func() { sub.Func(data) }()
actualReceiver++
} else if sub.Chan != nil {
err := syncext.WriteChannelWithContext(ctx, sub.Chan, data)
if err != nil {
return err
}
actualReceiver++
}
return nil
}()
if err != nil {
return subscriber, actualReceiver, err
}
}
return subscriber, actualReceiver, nil
}
// PublishWithTimeout sends `data` to all subscriber
// buffered - if one is currently not listening, we wait (but wait at most `timeout` - if the timeout is exceeded then actualReceiver < subscriber)
func (ps *PubSub[TNamespace, TData]) PublishWithTimeout(ns TNamespace, data TData, timeout time.Duration) (subscriber int, actualReceiver int) {
ps.masterLock.Lock()
subs := langext.ArrCopy(ps.subscriptions[ns])
ps.masterLock.Unlock()
subscriber = len(subs)
actualReceiver = 0
for _, sub := range subs {
func() {
sub.subLock.Lock()
defer sub.subLock.Unlock()
if sub.Func != nil {
go func() { sub.Func(data) }()
actualReceiver++
} else if sub.Chan != nil {
ok := syncext.WriteChannelWithTimeout(sub.Chan, data, timeout)
if ok {
actualReceiver++
}
}
}()
}
return subscriber, actualReceiver
}
// PublishAsync sends `data` to all subscriber
// does not wait for subscriber (this method returns immediately), waits at most {timeout} seconds on channels (async)
func (ps *PubSub[TNamespace, TData]) PublishAsync(ns TNamespace, data TData, timeout time.Duration) (subscriber int) {
ps.masterLock.Lock()
subs := langext.ArrCopy(ps.subscriptions[ns])
ps.masterLock.Unlock()
subscriber = len(subs)
for _, sub := range subs {
go func() {
sub.subLock.Lock()
defer sub.subLock.Unlock()
if sub.Func != nil {
go func() { sub.Func(data) }()
} else if sub.Chan != nil {
_ = syncext.WriteChannelWithTimeout(sub.Chan, data, timeout)
}
}()
}
return subscriber
}
func (ps *PubSub[TNamespace, TData]) SubscribeByCallback(ns TNamespace, fn func(TData)) PubSubSubscription {
ps.masterLock.Lock()
defer ps.masterLock.Unlock()
sub := &pubSubSubscription[TNamespace, TData]{ID: xid.New().String(), namespace: ns, parent: ps, subLock: &sync.Mutex{}, Func: fn, UnsubChan: nil}
ps.subscriptions[ns] = append(ps.subscriptions[ns], sub)
return sub
}
func (ps *PubSub[TNamespace, TData]) SubscribeByChan(ns TNamespace, chanBufferSize int) (chan TData, PubSubSubscription) {
ps.masterLock.Lock()
defer ps.masterLock.Unlock()
msgCh := make(chan TData, chanBufferSize)
sub := &pubSubSubscription[TNamespace, TData]{ID: xid.New().String(), namespace: ns, parent: ps, subLock: &sync.Mutex{}, Chan: msgCh, UnsubChan: nil}
ps.subscriptions[ns] = append(ps.subscriptions[ns], sub)
return msgCh, sub
}
func (ps *PubSub[TNamespace, TData]) SubscribeByIter(ns TNamespace, chanBufferSize int) (iter.Seq[TData], PubSubSubscription) {
ps.masterLock.Lock()
defer ps.masterLock.Unlock()
msgCh := make(chan TData, chanBufferSize)
unsubChan := make(chan bool, 8)
sub := &pubSubSubscription[TNamespace, TData]{ID: xid.New().String(), namespace: ns, parent: ps, subLock: &sync.Mutex{}, Chan: msgCh, UnsubChan: unsubChan}
ps.subscriptions[ns] = append(ps.subscriptions[ns], sub)
iterFun := func(yield func(TData) bool) {
for {
select {
case msg := <-msgCh:
if !yield(msg) {
sub.Unsubscribe()
return
}
case <-sub.UnsubChan:
sub.Unsubscribe()
return
}
}
}
return iterFun, sub
}
func (ps *PubSub[TNamespace, TData]) unsubscribe(p *pubSubSubscription[TNamespace, TData]) {
ps.masterLock.Lock()
defer ps.masterLock.Unlock()
p.subLock.Lock()
defer p.subLock.Unlock()
if p.Chan != nil {
close(p.Chan)
p.Chan = nil
}
if p.UnsubChan != nil {
syncext.WriteNonBlocking(p.UnsubChan, true)
close(p.UnsubChan)
p.UnsubChan = nil
}
ps.subscriptions[p.namespace] = langext.ArrFilter(ps.subscriptions[p.namespace], func(v *pubSubSubscription[TNamespace, TData]) bool {
return v.ID != p.ID
})
if len(ps.subscriptions[p.namespace]) == 0 {
delete(ps.subscriptions, p.namespace)
}
}
+438
View File
@@ -0,0 +1,438 @@
package dataext
import (
"context"
"sync"
"testing"
"time"
)
func TestNewPubSub(t *testing.T) {
ps := NewPubSub[string, string](10)
if ps == nil {
t.Fatal("NewPubSub returned nil")
}
if ps.masterLock == nil {
t.Fatal("masterLock is nil")
}
if ps.subscriptions == nil {
t.Fatal("subscriptions is nil")
}
}
func TestPubSub_Namespaces(t *testing.T) {
ps := NewPubSub[string, string](10)
// Initially no namespaces
namespaces := ps.Namespaces()
if len(namespaces) != 0 {
t.Fatalf("Expected 0 namespaces, got %d", len(namespaces))
}
// Add a subscription to create a namespace
_, sub1 := ps.SubscribeByChan("test-ns1", 1)
defer sub1.Unsubscribe()
// Add another subscription to a different namespace
_, sub2 := ps.SubscribeByChan("test-ns2", 1)
defer sub2.Unsubscribe()
// Check namespaces
namespaces = ps.Namespaces()
if len(namespaces) != 2 {
t.Fatalf("Expected 2 namespaces, got %d", len(namespaces))
}
// Check if namespaces contain the expected values
found1, found2 := false, false
for _, ns := range namespaces {
if ns == "test-ns1" {
found1 = true
}
if ns == "test-ns2" {
found2 = true
}
}
if !found1 || !found2 {
t.Fatalf("Expected to find both namespaces, found ns1: %v, ns2: %v", found1, found2)
}
}
func TestPubSub_SubscribeByCallback(t *testing.T) {
ps := NewPubSub[string, string](10)
var received string
var wg sync.WaitGroup
wg.Add(1)
callback := func(msg string) {
received = msg
wg.Done()
}
sub := ps.SubscribeByCallback("test-ns", callback)
defer sub.Unsubscribe()
// Publish a message
subs, receivers := ps.Publish("test-ns", "hello")
if subs != 1 {
t.Fatalf("Expected 1 subscriber, got %d", subs)
}
if receivers != 1 {
t.Fatalf("Expected 1 receiver, got %d", receivers)
}
// Wait for the callback to be executed
wg.Wait()
if received != "hello" {
t.Fatalf("Expected to receive 'hello', got '%s'", received)
}
}
func TestPubSub_SubscribeByChan(t *testing.T) {
ps := NewPubSub[string, string](10)
ch, sub := ps.SubscribeByChan("test-ns", 1)
defer sub.Unsubscribe()
// Publish a message
subs, receivers := ps.Publish("test-ns", "hello")
if subs != 1 {
t.Fatalf("Expected 1 subscriber, got %d", subs)
}
if receivers != 1 {
t.Fatalf("Expected 1 receiver, got %d", receivers)
}
// Read from the channel with a timeout to avoid blocking
select {
case msg := <-ch:
if msg != "hello" {
t.Fatalf("Expected to receive 'hello', got '%s'", msg)
}
case <-time.After(time.Second):
t.Fatal("Timed out waiting for message")
}
}
func TestPubSub_SubscribeByIter(t *testing.T) {
ps := NewPubSub[string, string](10)
iterSeq, sub := ps.SubscribeByIter("test-ns", 1)
defer sub.Unsubscribe()
// Channel to communicate when message is received
done := make(chan bool)
goroutineDone := make(chan struct{})
received := false
// Start a goroutine to use the iterator
go func() {
defer close(goroutineDone)
for msg := range iterSeq {
if msg == "hello" {
received = true
done <- true
return // Stop iteration — triggers Unsubscribe via yield returning false
}
}
}()
// Give time for the iterator to start
time.Sleep(100 * time.Millisecond)
// Publish a message
ps.Publish("test-ns", "hello")
// Wait for the message to be received or timeout
select {
case <-done:
if !received {
t.Fatal("Message was received but not 'hello'")
}
case <-time.After(time.Second):
t.Fatal("Timed out waiting for message")
}
// Wait for the goroutine to fully exit so Unsubscribe (triggered by the
// iterator cleanup when yield returns false) has completed.
select {
case <-goroutineDone:
case <-time.After(time.Second):
t.Fatal("Timed out waiting for goroutine to finish")
}
subCount := ps.SubscriberCount("test-ns")
if subCount != 0 {
t.Fatalf("Expected 0 receivers, got %d", subCount)
}
}
func TestPubSub_Publish(t *testing.T) {
ps := NewPubSub[string, string](10)
// Test publishing to a namespace with no subscribers
subs, receivers := ps.Publish("empty-ns", "hello")
if subs != 0 {
t.Fatalf("Expected 0 subscribers, got %d", subs)
}
if receivers != 0 {
t.Fatalf("Expected 0 receivers, got %d", receivers)
}
// Add a subscriber
ch, sub := ps.SubscribeByChan("test-ns", 1)
defer sub.Unsubscribe()
// Publish a message
subs, receivers = ps.Publish("test-ns", "hello")
if subs != 1 {
t.Fatalf("Expected 1 subscriber, got %d", subs)
}
if receivers != 1 {
t.Fatalf("Expected 1 receiver, got %d", receivers)
}
// Verify the message was received
select {
case msg := <-ch:
if msg != "hello" {
t.Fatalf("Expected to receive 'hello', got '%s'", msg)
}
case <-time.After(time.Second):
t.Fatal("Timed out waiting for message")
}
// Test non-blocking behavior with a full channel
// First fill the channel
ps.Publish("test-ns", "fill")
// Now publish again - this should not block but skip the receiver
subs, receivers = ps.Publish("test-ns", "overflow")
if subs != 1 {
t.Fatalf("Expected 1 subscriber, got %d", subs)
}
// The receiver count might be 0 if the channel is full
// Drain the channel
<-ch
}
func TestPubSub_PublishWithTimeout(t *testing.T) {
ps := NewPubSub[string, string](10)
// Add a subscriber with a channel
ch, sub := ps.SubscribeByChan("test-ns", 1)
defer sub.Unsubscribe()
// Publish with a timeout
subs, receivers := ps.PublishWithTimeout("test-ns", "hello", 100*time.Millisecond)
if subs != 1 {
t.Fatalf("Expected 1 subscriber, got %d", subs)
}
if receivers != 1 {
t.Fatalf("Expected 1 receiver, got %d", receivers)
}
// Verify the message was received
select {
case msg := <-ch:
if msg != "hello" {
t.Fatalf("Expected to receive 'hello', got '%s'", msg)
}
case <-time.After(time.Second):
t.Fatal("Timed out waiting for message")
}
// Fill the channel
ps.Publish("test-ns", "fill")
// Test timeout behavior with a full channel
start := time.Now()
subs, receivers = ps.PublishWithTimeout("test-ns", "timeout-test", 50*time.Millisecond)
elapsed := time.Since(start)
if subs != 1 {
t.Fatalf("Expected 1 subscriber, got %d", subs)
}
// The receiver count should be 0 if the timeout occurred
if elapsed < 50*time.Millisecond {
t.Fatalf("Expected to wait at least 50ms, only waited %v", elapsed)
}
// Drain the channel
<-ch
}
func TestPubSub_PublishWithContext(t *testing.T) {
ps := NewPubSub[string, string](10)
// Add a subscriber with a channel
ch, sub := ps.SubscribeByChan("test-ns", 1)
defer sub.Unsubscribe()
// Create a context
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
// Publish with context
subs, receivers, err := ps.PublishWithContext(ctx, "test-ns", "hello")
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if subs != 1 {
t.Fatalf("Expected 1 subscriber, got %d", subs)
}
if receivers != 1 {
t.Fatalf("Expected 1 receiver, got %d", receivers)
}
// Verify the message was received
select {
case msg := <-ch:
if msg != "hello" {
t.Fatalf("Expected to receive 'hello', got '%s'", msg)
}
case <-time.After(time.Second):
t.Fatal("Timed out waiting for message")
}
// Fill the channel
ps.Publish("test-ns", "fill")
// Test context cancellation with a full channel
ctx, cancel = context.WithCancel(context.Background())
// Cancel the context after a short delay
go func() {
time.Sleep(50 * time.Millisecond)
cancel()
}()
start := time.Now()
subs, receivers, err = ps.PublishWithContext(ctx, "test-ns", "context-test")
elapsed := time.Since(start)
if subs != 1 {
t.Fatalf("Expected 1 subscriber, got %d", subs)
}
// Should get a context canceled error
if err == nil {
t.Fatal("Expected context canceled error, got nil")
}
if elapsed < 50*time.Millisecond {
t.Fatalf("Expected to wait at least 50ms, only waited %v", elapsed)
}
// Drain the channel
<-ch
}
func TestPubSub_Unsubscribe(t *testing.T) {
ps := NewPubSub[string, string](10)
// Add a subscriber
ch, sub := ps.SubscribeByChan("test-ns", 1)
// Publish a message
subs, receivers := ps.Publish("test-ns", "hello")
if subs != 1 {
t.Fatalf("Expected 1 subscriber, got %d", subs)
}
if receivers != 1 {
t.Fatalf("Expected 1 receiver, got %d", receivers)
}
// Verify the message was received
select {
case msg := <-ch:
if msg != "hello" {
t.Fatalf("Expected to receive 'hello', got '%s'", msg)
}
case <-time.After(time.Second):
t.Fatal("Timed out waiting for message")
}
// Unsubscribe
sub.Unsubscribe()
// Publish again
subs, receivers = ps.Publish("test-ns", "after-unsub")
if subs != 0 {
t.Fatalf("Expected 0 subscribers after unsubscribe, got %d", subs)
}
if receivers != 0 {
t.Fatalf("Expected 0 receivers after unsubscribe, got %d", receivers)
}
// Check that the namespace is removed
namespaces := ps.Namespaces()
if len(namespaces) != 0 {
t.Fatalf("Expected 0 namespaces after unsubscribe, got %d", len(namespaces))
}
}
func TestPubSub_MultipleSubscribers(t *testing.T) {
ps := NewPubSub[string, string](10)
// Add multiple subscribers
ch1, sub1 := ps.SubscribeByChan("test-ns", 1)
defer sub1.Unsubscribe()
ch2, sub2 := ps.SubscribeByChan("test-ns", 1)
defer sub2.Unsubscribe()
var received string
var wg sync.WaitGroup
wg.Add(1)
sub3 := ps.SubscribeByCallback("test-ns", func(msg string) {
received = msg
wg.Done()
})
defer sub3.Unsubscribe()
// Publish a message
subs, receivers := ps.Publish("test-ns", "hello")
if subs != 3 {
t.Fatalf("Expected 3 subscribers, got %d", subs)
}
if receivers != 3 {
t.Fatalf("Expected 3 receivers, got %d", receivers)
}
// Verify the message was received by all subscribers
select {
case msg := <-ch1:
if msg != "hello" {
t.Fatalf("Expected ch1 to receive 'hello', got '%s'", msg)
}
case <-time.After(time.Second):
t.Fatal("Timed out waiting for message on ch1")
}
select {
case msg := <-ch2:
if msg != "hello" {
t.Fatalf("Expected ch2 to receive 'hello', got '%s'", msg)
}
case <-time.After(time.Second):
t.Fatal("Timed out waiting for message on ch2")
}
// Wait for the callback
wg.Wait()
if received != "hello" {
t.Fatalf("Expected callback to receive 'hello', got '%s'", received)
}
}
+144
View File
@@ -0,0 +1,144 @@
package dataext
import "iter"
type RingBuffer[T any] struct {
items []T //
capacity int // max number of items the buffer can hold
size int // how many items are in the buffer
head int // ptr to next item
}
func NewRingBuffer[T any](capacity int) *RingBuffer[T] {
return &RingBuffer[T]{
items: make([]T, capacity),
capacity: capacity,
size: 0,
head: 0,
}
}
func (rb *RingBuffer[T]) Push(item T) {
if rb.size < rb.capacity {
rb.size++
}
rb.items[rb.head] = item
rb.head = (rb.head + 1) % rb.capacity
}
func (rb *RingBuffer[T]) PushPop(item T) *T {
if rb.size < rb.capacity {
rb.size++
rb.items[rb.head] = item
rb.head = (rb.head + 1) % rb.capacity
return nil
} else {
prev := rb.items[rb.head]
rb.items[rb.head] = item
rb.head = (rb.head + 1) % rb.capacity
return &prev
}
}
func (rb *RingBuffer[T]) Peek() (T, bool) {
if rb.size == 0 {
return *new(T), false
}
return rb.items[(rb.head-1+rb.capacity)%rb.capacity], true
}
func (rb *RingBuffer[T]) Items() []T {
if rb.size < rb.capacity {
return rb.items[:rb.size]
}
return append(rb.items[rb.head:], rb.items[:rb.head]...)
}
func (rb *RingBuffer[T]) Size() int {
return rb.size
}
func (rb *RingBuffer[T]) Capacity() int {
return rb.capacity
}
func (rb *RingBuffer[T]) Clear() {
rb.size = 0
rb.head = 0
}
func (rb *RingBuffer[T]) IsFull() bool {
return rb.size == rb.capacity
}
func (rb *RingBuffer[T]) At(i int) T {
if i < 0 || i >= rb.size {
panic("Index out of bounds")
}
if rb.size < rb.capacity {
return rb.items[i]
}
return rb.items[(rb.head+i)%rb.capacity]
}
func (rb *RingBuffer[T]) Get(i int) (T, bool) {
if i < 0 || i >= rb.size {
return *new(T), false
}
if rb.size < rb.capacity {
return rb.items[i], true
}
return rb.items[(rb.head+i)%rb.capacity], true
}
func (rb *RingBuffer[T]) Iter() iter.Seq[T] {
return func(yield func(T) bool) {
for i := 0; i < rb.size; i++ {
if !yield(rb.At(i)) {
return
}
}
}
}
func (rb *RingBuffer[T]) Iter2() iter.Seq2[int, T] {
return func(yield func(int, T) bool) {
for i := 0; i < rb.size; i++ {
if !yield(i, rb.At(i)) {
return
}
}
}
}
func (rb *RingBuffer[T]) Remove(fnEqual func(v T) bool) int {
// Mike [2024-11-13]: I *really* tried to write an in-place algorithm to remove elements
// But after carful consideration, I left that as an exercise for future readers
// It is, suprisingly, non-trivial, especially because the head-ptr must be weirdly updated
// And out At() method does not work correctly with {head<>0 && size<capacity}
dc := 0
b := make([]T, rb.capacity)
bsize := 0
for i := 0; i < rb.size; i++ {
comp := rb.At(i)
if fnEqual(comp) {
dc++
} else {
b[bsize] = comp
bsize++
}
}
if dc == 0 {
return 0
}
rb.items = b
rb.size = bsize
rb.head = bsize % rb.capacity
return dc
}
+447
View File
@@ -0,0 +1,447 @@
package dataext
import "testing"
func TestRingBufferPushAddsItem(t *testing.T) {
rb := NewRingBuffer[int](3)
rb.Push(1)
if rb.Size() != 1 {
t.Errorf("Expected size 1, got %d", rb.Size())
}
if item, _ := rb.Peek(); item != 1 {
t.Errorf("Expected item 1, got %d", item)
}
}
func TestRingBufferPushPopReturnsOldestItem(t *testing.T) {
rb := NewRingBuffer[int](3)
rb.Push(1)
rb.Push(2)
rb.Push(3)
if item := rb.PushPop(4); item == nil || *item != 1 {
t.Errorf("Expected item 1, got %v", item)
}
}
func TestRingBufferPeekReturnsLastPushedItem(t *testing.T) {
rb := NewRingBuffer[int](3)
rb.Push(1)
rb.Push(2)
if item, _ := rb.Peek(); item != 2 {
t.Errorf("Expected item 2, got %d", item)
}
}
func TestRingBufferOverflow1(t *testing.T) {
rb := NewRingBuffer[int](5)
rb.Push(1) // overriden
rb.Push(2) // overriden
rb.Push(3)
rb.Push(9)
rb.Push(4)
rb.Push(5)
rb.Push(7)
if rb.Size() != 5 {
t.Errorf("Expected size 4, got %d", rb.Size())
}
expected := []int{3, 9, 4, 5, 7}
items := rb.Items()
for i, item := range items {
if item != expected[i] {
t.Errorf("Expected item %d, got %d", expected[i], item)
}
}
}
func TestRingBufferItemsReturnsAllItems(t *testing.T) {
rb := NewRingBuffer[int](3)
rb.Push(1)
rb.Push(2)
rb.Push(3)
items := rb.Items()
expected := []int{1, 2, 3}
for i, item := range items {
if item != expected[i] {
t.Errorf("Expected item %d, got %d", expected[i], item)
}
}
}
func TestRingBufferClearEmptiesBuffer(t *testing.T) {
rb := NewRingBuffer[int](3)
rb.Push(1)
rb.Clear()
if rb.Size() != 0 {
t.Errorf("Expected size 0, got %d", rb.Size())
}
}
func TestRingBufferIsFullReturnsTrueWhenFull(t *testing.T) {
rb := NewRingBuffer[int](3)
rb.Push(1)
rb.Push(2)
rb.Push(3)
if !rb.IsFull() {
t.Errorf("Expected buffer to be full")
}
}
func TestRingBufferAtReturnsCorrectItem(t *testing.T) {
rb := NewRingBuffer[int](3)
rb.Push(1)
rb.Push(2)
rb.Push(3)
if item := rb.At(1); item != 2 {
t.Errorf("Expected item 2, got %d", item)
}
}
func TestRingBufferGetReturnsCorrectItem(t *testing.T) {
rb := NewRingBuffer[int](3)
rb.Push(1)
rb.Push(2)
rb.Push(3)
if item, ok := rb.Get(1); !ok || item != 2 {
t.Errorf("Expected item 2, got %d", item)
}
}
func TestRingBufferRemoveDeletesMatchingItems(t *testing.T) {
rb := NewRingBuffer[int](5)
rb.Push(1)
rb.Push(2)
rb.Push(3)
rb.Push(2)
rb.Push(4)
removed := rb.Remove(func(v int) bool { return v == 2 })
if removed != 2 {
t.Errorf("Expected 2 items removed, got %d", removed)
}
if rb.Size() != 3 {
t.Errorf("Expected size 3, got %d", rb.Size())
}
expected := []int{1, 3, 4}
items := rb.Items()
for i, item := range items {
if item != expected[i] {
t.Errorf("Expected item %d, got %d", expected[i], item)
}
}
}
func TestRingBufferRemoveDeletesMatchingItems2(t *testing.T) {
rb := NewRingBuffer[int](5)
rb.Push(1)
rb.Push(2)
rb.Push(3)
rb.Push(2)
rb.Push(4)
removed := rb.Remove(func(v int) bool { return v == 3 })
if removed != 1 {
t.Errorf("Expected 2 items removed, got %d", removed)
}
if rb.Size() != 4 {
t.Errorf("Expected size 3, got %d", rb.Size())
}
expected := []int{1, 2, 2, 4}
items := rb.Items()
for i, item := range items {
if item != expected[i] {
t.Errorf("Expected item %d, got %d", expected[i], item)
}
}
}
func TestRingBufferRemoveDeletesMatchingItems3(t *testing.T) {
rb := NewRingBuffer[int](5)
rb.Push(1)
rb.Push(2)
rb.Push(3)
rb.Push(9)
rb.Push(4)
removed := rb.Remove(func(v int) bool { return v == 3 })
if removed != 1 {
t.Errorf("Expected 2 items removed, got %d", removed)
}
if rb.Size() != 4 {
t.Errorf("Expected size 3, got %d", rb.Size())
}
expected := []int{1, 2, 9, 4}
items := rb.Items()
for i, item := range items {
if item != expected[i] {
t.Errorf("Expected item %d, got %d", expected[i], item)
}
}
}
func TestRingBufferRemoveDeletesMatchingItems4(t *testing.T) {
rb := NewRingBuffer[int](5)
rb.Push(1) // overriden
rb.Push(2) // overriden
rb.Push(3)
rb.Push(9)
rb.Push(4)
rb.Push(5)
rb.Push(7)
removed := rb.Remove(func(v int) bool { return v == 7 })
if removed != 1 {
t.Errorf("Expected 1 items removed, got %d", removed)
}
if rb.Size() != 4 {
t.Errorf("Expected size 4, got %d", rb.Size())
}
expected := []int{3, 9, 4, 5}
items := rb.Items()
for i, item := range items {
if item != expected[i] {
t.Errorf("Expected item %d, got %d", expected[i], item)
}
}
}
func TestRingBufferRemoveDeletesMatchingItems5(t *testing.T) {
rb := NewRingBuffer[int](5)
rb.Push(1) // overriden
rb.Push(2) // overriden
rb.Push(3)
rb.Push(9)
rb.Push(4)
rb.Push(5)
rb.Push(7)
removed := rb.Remove(func(v int) bool { return v == 3 })
if removed != 1 {
t.Errorf("Expected 1 items removed, got %d", removed)
}
if rb.Size() != 4 {
t.Errorf("Expected size 4, got %d", rb.Size())
}
expected := []int{9, 4, 5, 7}
items := rb.Items()
for i, item := range items {
if item != expected[i] {
t.Errorf("Expected item %d, got %d", expected[i], item)
}
}
}
func TestRingBufferRemoveDeletesMatchingItems6(t *testing.T) {
rb := NewRingBuffer[int](5)
rb.Push(1) // overriden
rb.Push(2) // overriden
rb.Push(3)
rb.Push(9)
rb.Push(4)
rb.Push(5)
rb.Push(7)
removed := rb.Remove(func(v int) bool { return v == 1 })
if removed != 0 {
t.Errorf("Expected 0 items removed, got %d", removed)
}
if rb.Size() != 5 {
t.Errorf("Expected size 5, got %d", rb.Size())
}
expected := []int{3, 9, 4, 5, 7}
items := rb.Items()
for i, item := range items {
if item != expected[i] {
t.Errorf("Expected item %d, got %d", expected[i], item)
}
}
if !rb.IsFull() {
t.Errorf("Expected buffer to not be full")
}
}
func TestRingBufferRemoveDeletesMatchingItems7(t *testing.T) {
rb := NewRingBuffer[int](5)
rb.Push(1) // overriden
rb.Push(2) // overriden
rb.Push(3)
rb.Push(9)
rb.Push(4)
rb.Push(5)
rb.Push(7)
removed := rb.Remove(func(v int) bool { return v == 9 })
if removed != 1 {
t.Errorf("Expected 1 items removed, got %d", removed)
}
if rb.Size() != 4 {
t.Errorf("Expected size 4, got %d", rb.Size())
}
expected := []int{3, 4, 5, 7}
items := rb.Items()
for i, item := range items {
if item != expected[i] {
t.Errorf("Expected item %d, got %d", expected[i], item)
}
}
if rb.IsFull() {
t.Errorf("Expected buffer to not be full")
}
}
func TestRingBufferAddItemsToFullRingBuffer(t *testing.T) {
rb := NewRingBuffer[int](3)
rb.Push(1)
rb.Push(2)
rb.Push(3)
rb.Push(4)
if rb.Size() != 3 {
t.Errorf("Expected size 3, got %d", rb.Size())
}
expected := []int{2, 3, 4}
items := rb.Items()
for i, item := range items {
if item != expected[i] {
t.Errorf("Expected item %d, got %d", expected[i], item)
}
}
}
func TestRingBufferAddItemsToNonFullRingBuffer(t *testing.T) {
rb := NewRingBuffer[int](3)
rb.Push(1)
rb.Push(2)
if rb.Size() != 2 {
t.Errorf("Expected size 2, got %d", rb.Size())
}
expected := []int{1, 2}
items := rb.Items()
for i, item := range items {
if item != expected[i] {
t.Errorf("Expected item %d, got %d", expected[i], item)
}
}
}
func TestRingBufferRemoveItemsFromNonFullRingBuffer(t *testing.T) {
rb := NewRingBuffer[int](3)
rb.Push(1)
rb.Push(2)
removed := rb.Remove(func(v int) bool { return v == 1 })
if removed != 1 {
t.Errorf("Expected 1 item removed, got %d", removed)
}
if rb.Size() != 1 {
t.Errorf("Expected size 1, got %d", rb.Size())
}
expected := []int{2}
items := rb.Items()
for i, item := range items {
if item != expected[i] {
t.Errorf("Expected item %d, got %d", expected[i], item)
}
}
}
func TestRingBufferRemoveItemsFromFullRingBuffer(t *testing.T) {
rb := NewRingBuffer[int](3)
rb.Push(1)
rb.Push(2)
rb.Push(3)
removed := rb.Remove(func(v int) bool { return v == 2 })
if removed != 1 {
t.Errorf("Expected 1 item removed, got %d", removed)
}
if rb.Size() != 2 {
t.Errorf("Expected size 2, got %d", rb.Size())
}
expected := []int{1, 3}
items := rb.Items()
for i, item := range items {
if item != expected[i] {
t.Errorf("Expected item %d, got %d", expected[i], item)
}
}
}
func TestRingBufferRemoveMultipleItemsFromRingBuffer(t *testing.T) {
rb := NewRingBuffer[int](5)
rb.Push(1)
rb.Push(2)
rb.Push(3)
rb.Push(2)
rb.Push(4)
removed := rb.Remove(func(v int) bool { return v == 2 })
if removed != 2 {
t.Errorf("Expected 2 items removed, got %d", removed)
}
if rb.Size() != 3 {
t.Errorf("Expected size 3, got %d", rb.Size())
}
expected := []int{1, 3, 4}
items := rb.Items()
for i, item := range items {
if item != expected[i] {
t.Errorf("Expected item %d, got %d", expected[i], item)
}
}
}
func TestRingBufferRemoveAllItemsFromRingBuffer(t *testing.T) {
rb := NewRingBuffer[int](3)
rb.Push(1)
rb.Push(2)
rb.Push(3)
removed := rb.Remove(func(v int) bool { return true })
if removed != 3 {
t.Errorf("Expected 3 items removed, got %d", removed)
}
if rb.Size() != 0 {
t.Errorf("Expected size 0, got %d", rb.Size())
}
}
func TestRingBufferRemoveNoItemsFromRingBuffer(t *testing.T) {
rb := NewRingBuffer[int](3)
rb.Push(1)
rb.Push(2)
rb.Push(3)
removed := rb.Remove(func(v int) bool { return false })
if removed != 0 {
t.Errorf("Expected 0 items removed, got %d", removed)
}
if rb.Size() != 3 {
t.Errorf("Expected size 3, got %d", rb.Size())
}
}
func TestRingBufferIteratesOverAllItems(t *testing.T) {
rb := NewRingBuffer[int](3)
rb.Push(1)
rb.Push(2)
rb.Push(3)
expected := []int{1, 2, 3}
i := 0
for item := range rb.Iter() {
if item != expected[i] {
t.Errorf("Expected item %d, got %d", expected[i], item)
}
i++
}
if i != len(expected) {
t.Errorf("Expected to iterate over %d items, but iterated over %d", len(expected), i)
}
}
func TestRingBufferIter2IteratesOverAllItemsWithIndices(t *testing.T) {
rb := NewRingBuffer[int](3)
rb.Push(1)
rb.Push(2)
rb.Push(3)
expected := []int{1, 2, 3}
i := 0
for index, item := range rb.Iter2() {
if index != i {
t.Errorf("Expected index %d, got %d", i, index)
}
if item != expected[i] {
t.Errorf("Expected item %d, got %d", expected[i], item)
}
i++
}
if i != len(expected) {
t.Errorf("Expected to iterate over %d items, but iterated over %d", len(expected), i)
}
}
+2 -3
View File
@@ -2,7 +2,6 @@ package dataext
import (
"errors"
"gogs.mikescher.com/BlackForestBytes/goext/langext"
"sync"
)
@@ -64,7 +63,7 @@ func (s *Stack[T]) OptPop() *T {
result := s.data[l-1]
s.data = s.data[:l-1]
return langext.Ptr(result)
return new(result)
}
func (s *Stack[T]) Peek() (T, error) {
@@ -94,7 +93,7 @@ func (s *Stack[T]) OptPeek() *T {
return nil
}
return langext.Ptr(s.data[l-1])
return new(s.data[l-1])
}
func (s *Stack[T]) Length() int {
+98
View File
@@ -0,0 +1,98 @@
package dataext
import (
"errors"
"sync"
"testing"
)
func TestStack_PushPop(t *testing.T) {
s := NewStack[int](false, 4)
s.Push(1)
s.Push(2)
s.Push(3)
if s.Length() != 3 {
t.Fatalf("Length=%d", s.Length())
}
if s.Empty() {
t.Fatal("should not be empty")
}
v, err := s.Pop()
if err != nil || v != 3 {
t.Fatalf("Pop got (%d,%v)", v, err)
}
v, err = s.Pop()
if err != nil || v != 2 {
t.Fatalf("Pop got (%d,%v)", v, err)
}
v, err = s.Pop()
if err != nil || v != 1 {
t.Fatalf("Pop got (%d,%v)", v, err)
}
}
func TestStack_PopEmpty(t *testing.T) {
s := NewStack[int](false, 0)
_, err := s.Pop()
if !errors.Is(err, ErrEmptyStack) {
t.Fatalf("expected ErrEmptyStack, got %v", err)
}
if !s.Empty() {
t.Fatal("should be empty")
}
}
func TestStack_Peek(t *testing.T) {
s := NewStack[string](false, 0)
if _, err := s.Peek(); !errors.Is(err, ErrEmptyStack) {
t.Fatalf("expected ErrEmptyStack got %v", err)
}
s.Push("a")
s.Push("b")
v, err := s.Peek()
if err != nil || v != "b" {
t.Fatalf("Peek got (%q,%v)", v, err)
}
if s.Length() != 2 {
t.Fatal("Peek must not pop")
}
}
func TestStack_OptPopOptPeek(t *testing.T) {
s := NewStack[int](false, 0)
if s.OptPop() != nil {
t.Fatal("OptPop on empty should return nil")
}
if s.OptPeek() != nil {
t.Fatal("OptPeek on empty should return nil")
}
s.Push(7)
if p := s.OptPeek(); p == nil || *p != 7 {
t.Fatalf("OptPeek bad")
}
if p := s.OptPop(); p == nil || *p != 7 {
t.Fatalf("OptPop bad")
}
if !s.Empty() {
t.Fatal("should be empty after OptPop")
}
}
func TestStack_ThreadSafe(t *testing.T) {
s := NewStack[int](true, 0)
var wg sync.WaitGroup
const n = 200
wg.Add(n)
for i := 0; i < n; i++ {
go func(v int) {
defer wg.Done()
s.Push(v)
}(i)
}
wg.Wait()
if s.Length() != n {
t.Fatalf("Length=%d want %d", s.Length(), n)
}
}
+2 -2
View File
@@ -6,7 +6,7 @@ import (
"encoding/binary"
"errors"
"fmt"
"gogs.mikescher.com/BlackForestBytes/goext/langext"
"git.blackforestbytes.com/BlackForestBytes/goext/langext"
"hash"
"io"
"reflect"
@@ -82,7 +82,7 @@ func binarize(writer io.Writer, dat reflect.Value, opt StructHashOptions) error
err = binary.Write(writer, binary.LittleEndian, uint8(dat.Kind()))
switch dat.Kind() {
case reflect.Ptr, reflect.Map, reflect.Array, reflect.Chan, reflect.Slice, reflect.Interface:
case reflect.Pointer, reflect.Map, reflect.Array, reflect.Chan, reflect.Slice, reflect.Interface:
if dat.IsNil() {
err = binary.Write(writer, binary.LittleEndian, uint64(0))
if err != nil {
+2 -3
View File
@@ -1,8 +1,7 @@
package dataext
import (
"gogs.mikescher.com/BlackForestBytes/goext/langext"
"gogs.mikescher.com/BlackForestBytes/goext/tst"
"git.blackforestbytes.com/BlackForestBytes/goext/tst"
"testing"
)
@@ -46,7 +45,7 @@ func TestStructHashSimpleStruct(t *testing.T) {
tst.AssertHexEqual(t, "5d09090dc34ac59dd645f197a255f653387723de3afa1b614721ea5a081c675f", noErrStructHash(t, t0{
F1: 10,
F2: []string{"1", "2", "3"},
F3: langext.Ptr(99),
F3: new(99),
}))
}
+256
View File
@@ -0,0 +1,256 @@
package dataext
import "sync"
// SyncMap is a thread-safe map implementation for generic key-value pairs.
// All functions aresafe to be called in parallel.
type SyncMap[TKey comparable, TData any] struct {
data map[TKey]TData
lock sync.Mutex
}
func NewSyncMap[TKey comparable, TData any]() *SyncMap[TKey, TData] {
return &SyncMap[TKey, TData]{data: make(map[TKey]TData), lock: sync.Mutex{}}
}
// Set sets the value for the provided key
func (s *SyncMap[TKey, TData]) Set(key TKey, data TData) {
s.lock.Lock()
defer s.lock.Unlock()
if s.data == nil {
s.data = make(map[TKey]TData)
}
s.data[key] = data
}
// SetIfNotContains sets the value for the provided key if it does not already exist
func (s *SyncMap[TKey, TData]) SetIfNotContains(key TKey, data TData) bool {
s.lock.Lock()
defer s.lock.Unlock()
if s.data == nil {
s.data = make(map[TKey]TData)
}
if _, existsInPreState := s.data[key]; existsInPreState {
return false
}
s.data[key] = data
return true
}
// SetIfNotContainsFunc sets the value for the provided key using the provided function
func (s *SyncMap[TKey, TData]) SetIfNotContainsFunc(key TKey, data func() TData) bool {
s.lock.Lock()
defer s.lock.Unlock()
if s.data == nil {
s.data = make(map[TKey]TData)
}
if _, existsInPreState := s.data[key]; existsInPreState {
return false
}
s.data[key] = data()
return true
}
// Get retrieves the value for the provided key
func (s *SyncMap[TKey, TData]) Get(key TKey) (TData, bool) {
s.lock.Lock()
defer s.lock.Unlock()
if s.data == nil {
s.data = make(map[TKey]TData)
}
if v, ok := s.data[key]; ok {
return v, true
} else {
return *new(TData), false
}
}
// GetAndSetIfNotContains returns the existing value if the key exists.
// Otherwise, it sets the provided value and returns it.
func (s *SyncMap[TKey, TData]) GetAndSetIfNotContains(key TKey, data TData) TData {
s.lock.Lock()
defer s.lock.Unlock()
if s.data == nil {
s.data = make(map[TKey]TData)
}
if v, ok := s.data[key]; ok {
return v
} else {
s.data[key] = data
return data
}
}
// GetAndSetIfNotContainsFunc returns the existing value if the key exists.
// Otherwise, it calls the provided function to generate the value, sets it, and returns it.
func (s *SyncMap[TKey, TData]) GetAndSetIfNotContainsFunc(key TKey, data func() TData) TData {
s.lock.Lock()
defer s.lock.Unlock()
if s.data == nil {
s.data = make(map[TKey]TData)
}
if v, ok := s.data[key]; ok {
return v
} else {
dataObj := data()
s.data[key] = dataObj
return dataObj
}
}
// Delete removes the entry with the provided key and returns true if the key existed before.
func (s *SyncMap[TKey, TData]) Delete(key TKey) bool {
s.lock.Lock()
defer s.lock.Unlock()
if s.data == nil {
s.data = make(map[TKey]TData)
}
_, ok := s.data[key]
delete(s.data, key)
return ok
}
// DeleteIf deletes all entries that match the provided function and returns the number of removed entries.
func (s *SyncMap[TKey, TData]) DeleteIf(fn func(key TKey, data TData) bool) int {
s.lock.Lock()
defer s.lock.Unlock()
if s.data == nil {
s.data = make(map[TKey]TData)
}
rm := 0
for k, v := range s.data {
if fn(k, v) {
delete(s.data, k)
rm++
}
}
return rm
}
// UpdateIfExists updates the value if the key exists, otherwise it does nothing.
func (s *SyncMap[TKey, TData]) UpdateIfExists(key TKey, fn func(data TData) TData) bool {
s.lock.Lock()
defer s.lock.Unlock()
if s.data == nil {
s.data = make(map[TKey]TData)
}
if v, ok := s.data[key]; ok {
s.data[key] = fn(v)
return true
} else {
return false
}
}
// UpdateOrInsert updates the value if the key exists, otherwise it inserts the provided `insertValue`.
func (s *SyncMap[TKey, TData]) UpdateOrInsert(key TKey, fn func(data TData) TData, insertValue TData) bool {
s.lock.Lock()
defer s.lock.Unlock()
if s.data == nil {
s.data = make(map[TKey]TData)
}
if v, ok := s.data[key]; ok {
s.data[key] = fn(v)
return true
} else {
s.data[key] = insertValue
return false
}
}
// Clear removes all entries from the map.
func (s *SyncMap[TKey, TData]) Clear() {
s.lock.Lock()
defer s.lock.Unlock()
s.data = make(map[TKey]TData)
}
// Contains checks if the map contains the provided key.
func (s *SyncMap[TKey, TData]) Contains(key TKey) bool {
s.lock.Lock()
defer s.lock.Unlock()
if s.data == nil {
s.data = make(map[TKey]TData)
}
_, ok := s.data[key]
return ok
}
// GetAllKeys returns a copy (!) of all keys in the map.
func (s *SyncMap[TKey, TData]) GetAllKeys() []TKey {
s.lock.Lock()
defer s.lock.Unlock()
if s.data == nil {
s.data = make(map[TKey]TData)
}
r := make([]TKey, 0, len(s.data))
for k := range s.data {
r = append(r, k)
}
return r
}
// GetAllValues returns a copy (!) of all values in the map.
func (s *SyncMap[TKey, TData]) GetAllValues() []TData {
s.lock.Lock()
defer s.lock.Unlock()
if s.data == nil {
s.data = make(map[TKey]TData)
}
r := make([]TData, 0, len(s.data))
for _, v := range s.data {
r = append(r, v)
}
return r
}
// Count returns the number of entries in the map.
func (s *SyncMap[TKey, TData]) Count() int {
s.lock.Lock()
defer s.lock.Unlock()
if s.data == nil {
s.data = make(map[TKey]TData)
}
return len(s.data)
}
+176
View File
@@ -0,0 +1,176 @@
package dataext
import (
"sort"
"sync"
"testing"
)
func TestSyncMap_SetGet(t *testing.T) {
m := NewSyncMap[string, int]()
m.Set("a", 1)
v, ok := m.Get("a")
if !ok || v != 1 {
t.Fatalf("got (%d,%v)", v, ok)
}
if _, ok := m.Get("missing"); ok {
t.Fatal("expected missing")
}
}
func TestSyncMap_SetIfNotContains(t *testing.T) {
m := NewSyncMap[string, int]()
if !m.SetIfNotContains("a", 1) {
t.Fatal("first set should succeed")
}
if m.SetIfNotContains("a", 2) {
t.Fatal("second set should fail")
}
v, _ := m.Get("a")
if v != 1 {
t.Fatalf("expected unchanged got %d", v)
}
}
func TestSyncMap_SetIfNotContainsFunc(t *testing.T) {
m := NewSyncMap[string, int]()
calls := 0
if !m.SetIfNotContainsFunc("a", func() int { calls++; return 5 }) {
t.Fatal("first should succeed")
}
if m.SetIfNotContainsFunc("a", func() int { calls++; return 6 }) {
t.Fatal("second should fail")
}
if calls != 1 {
t.Fatalf("calls=%d want 1", calls)
}
}
func TestSyncMap_GetAndSetIfNotContains(t *testing.T) {
m := NewSyncMap[string, int]()
if v := m.GetAndSetIfNotContains("a", 10); v != 10 {
t.Fatalf("got %d", v)
}
if v := m.GetAndSetIfNotContains("a", 99); v != 10 {
t.Fatalf("got %d", v)
}
}
func TestSyncMap_GetAndSetIfNotContainsFunc(t *testing.T) {
m := NewSyncMap[string, int]()
calls := 0
if v := m.GetAndSetIfNotContainsFunc("a", func() int { calls++; return 1 }); v != 1 {
t.Fatalf("got %d", v)
}
if v := m.GetAndSetIfNotContainsFunc("a", func() int { calls++; return 2 }); v != 1 {
t.Fatalf("got %d", v)
}
if calls != 1 {
t.Fatalf("calls=%d", calls)
}
}
func TestSyncMap_Delete(t *testing.T) {
m := NewSyncMap[string, int]()
m.Set("a", 1)
if !m.Delete("a") {
t.Fatal("delete existing returned false")
}
if m.Delete("a") {
t.Fatal("delete missing returned true")
}
}
func TestSyncMap_DeleteIf(t *testing.T) {
m := NewSyncMap[string, int]()
m.Set("a", 1)
m.Set("b", 2)
m.Set("c", 3)
rm := m.DeleteIf(func(k string, v int) bool { return v%2 == 1 })
if rm != 2 {
t.Fatalf("removed=%d", rm)
}
if m.Count() != 1 {
t.Fatalf("count=%d", m.Count())
}
}
func TestSyncMap_UpdateIfExists(t *testing.T) {
m := NewSyncMap[string, int]()
if m.UpdateIfExists("a", func(v int) int { return v + 1 }) {
t.Fatal("should be false on missing key")
}
m.Set("a", 5)
if !m.UpdateIfExists("a", func(v int) int { return v + 1 }) {
t.Fatal("should be true on existing")
}
v, _ := m.Get("a")
if v != 6 {
t.Fatalf("v=%d", v)
}
}
func TestSyncMap_UpdateOrInsert(t *testing.T) {
m := NewSyncMap[string, int]()
if m.UpdateOrInsert("a", func(v int) int { return v + 1 }, 100) {
t.Fatal("should return false on insert")
}
if v, _ := m.Get("a"); v != 100 {
t.Fatalf("v=%d", v)
}
if !m.UpdateOrInsert("a", func(v int) int { return v + 1 }, 100) {
t.Fatal("should return true on update")
}
if v, _ := m.Get("a"); v != 101 {
t.Fatalf("v=%d", v)
}
}
func TestSyncMap_ClearContains(t *testing.T) {
m := NewSyncMap[string, int]()
m.Set("a", 1)
if !m.Contains("a") {
t.Fatal("Contains should be true")
}
m.Clear()
if m.Contains("a") {
t.Fatal("after Clear should be false")
}
if m.Count() != 0 {
t.Fatalf("count=%d", m.Count())
}
}
func TestSyncMap_GetAllKeysValues(t *testing.T) {
m := NewSyncMap[string, int]()
m.Set("a", 1)
m.Set("b", 2)
m.Set("c", 3)
keys := m.GetAllKeys()
sort.Strings(keys)
if len(keys) != 3 || keys[0] != "a" || keys[2] != "c" {
t.Fatalf("keys=%v", keys)
}
vals := m.GetAllValues()
sort.Ints(vals)
if len(vals) != 3 || vals[0] != 1 || vals[2] != 3 {
t.Fatalf("vals=%v", vals)
}
}
func TestSyncMap_Concurrent(t *testing.T) {
m := NewSyncMap[int, int]()
var wg sync.WaitGroup
const n = 200
wg.Add(n)
for i := 0; i < n; i++ {
go func(k int) {
defer wg.Done()
m.Set(k, k*2)
}(i)
}
wg.Wait()
if m.Count() != n {
t.Fatalf("count=%d want %d", m.Count(), n)
}
}
+143
View File
@@ -0,0 +1,143 @@
package dataext
import "sync"
type SyncRingSet[TData comparable] struct {
data map[TData]bool
lock sync.Mutex
ring *RingBuffer[TData]
}
func NewSyncRingSet[TData comparable](capacity int) *SyncRingSet[TData] {
return &SyncRingSet[TData]{
data: make(map[TData]bool, capacity+1),
lock: sync.Mutex{},
ring: NewRingBuffer[TData](capacity),
}
}
// Add adds `value` to the set
// returns true if the value was actually inserted (value did not exist beforehand)
// returns false if the value already existed
func (s *SyncRingSet[TData]) Add(value TData) bool {
s.lock.Lock()
defer s.lock.Unlock()
if s.data == nil {
s.data = make(map[TData]bool)
}
_, existsInPreState := s.data[value]
if existsInPreState {
return false
}
prev := s.ring.PushPop(value)
s.data[value] = true
if prev != nil {
delete(s.data, *prev)
}
return true
}
func (s *SyncRingSet[TData]) AddAll(values []TData) {
s.lock.Lock()
defer s.lock.Unlock()
if s.data == nil {
s.data = make(map[TData]bool)
}
for _, value := range values {
_, existsInPreState := s.data[value]
if existsInPreState {
continue
}
prev := s.ring.PushPop(value)
s.data[value] = true
if prev != nil {
delete(s.data, *prev)
}
}
}
func (s *SyncRingSet[TData]) Remove(value TData) bool {
s.lock.Lock()
defer s.lock.Unlock()
if s.data == nil {
s.data = make(map[TData]bool)
}
_, existsInPreState := s.data[value]
if !existsInPreState {
return false
}
delete(s.data, value)
s.ring.Remove(func(v TData) bool { return value == v })
return true
}
func (s *SyncRingSet[TData]) RemoveAll(values []TData) {
s.lock.Lock()
defer s.lock.Unlock()
if s.data == nil {
s.data = make(map[TData]bool)
}
for _, value := range values {
delete(s.data, value)
s.ring.Remove(func(v TData) bool { return value == v })
}
}
func (s *SyncRingSet[TData]) Contains(value TData) bool {
s.lock.Lock()
defer s.lock.Unlock()
if s.data == nil {
s.data = make(map[TData]bool)
}
_, ok := s.data[value]
return ok
}
func (s *SyncRingSet[TData]) Get() []TData {
s.lock.Lock()
defer s.lock.Unlock()
if s.data == nil {
s.data = make(map[TData]bool)
}
r := make([]TData, 0, len(s.data))
for k := range s.data {
r = append(r, k)
}
return r
}
// AddIfNotContains
// returns true if the value was actually added (value did not exist beforehand)
// returns false if the value already existed
func (s *SyncRingSet[TData]) AddIfNotContains(key TData) bool {
return s.Add(key)
}
// RemoveIfContains
// returns true if the value was actually removed (value did exist beforehand)
// returns false if the value did not exist in the set
func (s *SyncRingSet[TData]) RemoveIfContains(key TData) bool {
return s.Remove(key)
}
+84
View File
@@ -0,0 +1,84 @@
package dataext
import (
"sort"
"testing"
)
func TestSyncRingSet_AddAndContains(t *testing.T) {
s := NewSyncRingSet[int](3)
if !s.Add(1) {
t.Fatal("first Add(1) should be true")
}
if s.Add(1) {
t.Fatal("duplicate Add(1) should be false")
}
if !s.Contains(1) {
t.Fatal("expected Contains(1)")
}
}
func TestSyncRingSet_CapacityEvicts(t *testing.T) {
s := NewSyncRingSet[int](3)
s.Add(1)
s.Add(2)
s.Add(3)
s.Add(4) // should evict the oldest (1)
if s.Contains(1) {
t.Fatal("1 should have been evicted")
}
for _, v := range []int{2, 3, 4} {
if !s.Contains(v) {
t.Fatalf("expected %d", v)
}
}
}
func TestSyncRingSet_Remove(t *testing.T) {
s := NewSyncRingSet[string](3)
s.Add("a")
s.Add("b")
if !s.Remove("a") {
t.Fatal("remove existing failed")
}
if s.Remove("a") {
t.Fatal("remove missing returned true")
}
if s.Contains("a") {
t.Fatal("a should be gone")
}
}
func TestSyncRingSet_AddAllRemoveAll(t *testing.T) {
s := NewSyncRingSet[int](10)
s.AddAll([]int{1, 2, 3, 2})
out := s.Get()
sort.Ints(out)
if len(out) != 3 {
t.Fatalf("got %v", out)
}
s.RemoveAll([]int{1, 99})
if s.Contains(1) {
t.Fatal("1 should be removed")
}
if !s.Contains(2) || !s.Contains(3) {
t.Fatal("2/3 should remain")
}
}
func TestSyncRingSet_AddIfNotContainsRemoveIfContains(t *testing.T) {
s := NewSyncRingSet[string](5)
if !s.AddIfNotContains("x") {
t.Fatal("first should succeed")
}
if s.AddIfNotContains("x") {
t.Fatal("second should fail")
}
if !s.RemoveIfContains("x") {
t.Fatal("remove existing failed")
}
if s.RemoveIfContains("x") {
t.Fatal("remove missing returned true")
}
}
+57 -3
View File
@@ -7,6 +7,13 @@ type SyncSet[TData comparable] struct {
lock sync.Mutex
}
func NewSyncSet[TData comparable]() *SyncSet[TData] {
return &SyncSet[TData]{data: make(map[TData]bool), lock: sync.Mutex{}}
}
// Add adds `value` to the set
// returns true if the value was actually inserted (value did not exist beforehand)
// returns false if the value already existed
func (s *SyncSet[TData]) Add(value TData) bool {
s.lock.Lock()
defer s.lock.Unlock()
@@ -15,10 +22,13 @@ func (s *SyncSet[TData]) Add(value TData) bool {
s.data = make(map[TData]bool)
}
_, ok := s.data[value]
s.data[value] = true
_, existsInPreState := s.data[value]
if existsInPreState {
return false
}
return !ok
s.data[value] = true
return true
}
func (s *SyncSet[TData]) AddAll(values []TData) {
@@ -34,6 +44,36 @@ func (s *SyncSet[TData]) AddAll(values []TData) {
}
}
func (s *SyncSet[TData]) Remove(value TData) bool {
s.lock.Lock()
defer s.lock.Unlock()
if s.data == nil {
s.data = make(map[TData]bool)
}
_, existsInPreState := s.data[value]
if !existsInPreState {
return false
}
delete(s.data, value)
return true
}
func (s *SyncSet[TData]) RemoveAll(values []TData) {
s.lock.Lock()
defer s.lock.Unlock()
if s.data == nil {
s.data = make(map[TData]bool)
}
for _, value := range values {
delete(s.data, value)
}
}
func (s *SyncSet[TData]) Contains(value TData) bool {
s.lock.Lock()
defer s.lock.Unlock()
@@ -63,3 +103,17 @@ func (s *SyncSet[TData]) Get() []TData {
return r
}
// AddIfNotContains
// returns true if the value was actually added (value did not exist beforehand)
// returns false if the value already existed
func (s *SyncSet[TData]) AddIfNotContains(key TData) bool {
return s.Add(key)
}
// RemoveIfContains
// returns true if the value was actually removed (value did exist beforehand)
// returns false if the value did not exist in the set
func (s *SyncSet[TData]) RemoveIfContains(key TData) bool {
return s.Remove(key)
}
+82
View File
@@ -0,0 +1,82 @@
package dataext
import (
"sort"
"testing"
)
func TestSyncSet_Add(t *testing.T) {
s := NewSyncSet[string]()
if !s.Add("a") {
t.Fatal("first add should be true")
}
if s.Add("a") {
t.Fatal("duplicate add should be false")
}
if !s.Contains("a") {
t.Fatal("Contains a should be true")
}
}
func TestSyncSet_AddAll(t *testing.T) {
s := NewSyncSet[int]()
s.AddAll([]int{1, 2, 3, 2})
if !s.Contains(1) || !s.Contains(2) || !s.Contains(3) {
t.Fatal("missing items")
}
if len(s.Get()) != 3 {
t.Fatalf("got len %d", len(s.Get()))
}
}
func TestSyncSet_Remove(t *testing.T) {
s := NewSyncSet[string]()
s.Add("a")
if !s.Remove("a") {
t.Fatal("remove existing failed")
}
if s.Remove("a") {
t.Fatal("remove missing returned true")
}
if s.Contains("a") {
t.Fatal("still contains after remove")
}
}
func TestSyncSet_RemoveAll(t *testing.T) {
s := NewSyncSet[int]()
s.AddAll([]int{1, 2, 3})
s.RemoveAll([]int{1, 2, 99})
if s.Contains(1) || s.Contains(2) {
t.Fatal("should be removed")
}
if !s.Contains(3) {
t.Fatal("3 should remain")
}
}
func TestSyncSet_Get(t *testing.T) {
s := NewSyncSet[int]()
s.AddAll([]int{3, 1, 2})
out := s.Get()
sort.Ints(out)
if len(out) != 3 || out[0] != 1 || out[2] != 3 {
t.Fatalf("out=%v", out)
}
}
func TestSyncSet_AddIfNotContainsRemoveIfContains(t *testing.T) {
s := NewSyncSet[string]()
if !s.AddIfNotContains("x") {
t.Fatal("first AddIfNotContains failed")
}
if s.AddIfNotContains("x") {
t.Fatal("second AddIfNotContains succeeded")
}
if !s.RemoveIfContains("x") {
t.Fatal("RemoveIfContains failed")
}
if s.RemoveIfContains("x") {
t.Fatal("RemoveIfContains on missing succeeded")
}
}
+241
View File
@@ -0,0 +1,241 @@
package dataext
type ValueGroup interface {
TupleLength() int
TupleValues() []any
}
// ----------------------------------------------------------------------------
type Single[T1 any] struct {
V1 T1
}
func (s Single[T1]) TupleLength() int {
return 1
}
func (s Single[T1]) TupleValues() []any {
return []any{s.V1}
}
func NewSingle[T1 any](v1 T1) Single[T1] {
return Single[T1]{V1: v1}
}
func NewTuple1[T1 any](v1 T1) Single[T1] {
return Single[T1]{V1: v1}
}
// ----------------------------------------------------------------------------
type Tuple[T1 any, T2 any] struct {
V1 T1
V2 T2
}
func (t Tuple[T1, T2]) TupleLength() int {
return 2
}
func (t Tuple[T1, T2]) TupleValues() []any {
return []any{t.V1, t.V2}
}
func NewTuple[T1 any, T2 any](v1 T1, v2 T2) Tuple[T1, T2] {
return Tuple[T1, T2]{V1: v1, V2: v2}
}
func NewTuple2[T1 any, T2 any](v1 T1, v2 T2) Tuple[T1, T2] {
return Tuple[T1, T2]{V1: v1, V2: v2}
}
// ----------------------------------------------------------------------------
type Triple[T1 any, T2 any, T3 any] struct {
V1 T1
V2 T2
V3 T3
}
func (t Triple[T1, T2, T3]) TupleLength() int {
return 3
}
func (t Triple[T1, T2, T3]) TupleValues() []any {
return []any{t.V1, t.V2, t.V3}
}
func NewTriple[T1 any, T2 any, T3 any](v1 T1, v2 T2, v3 T3) Triple[T1, T2, T3] {
return Triple[T1, T2, T3]{V1: v1, V2: v2, V3: v3}
}
func NewTuple3[T1 any, T2 any, T3 any](v1 T1, v2 T2, v3 T3) Triple[T1, T2, T3] {
return Triple[T1, T2, T3]{V1: v1, V2: v2, V3: v3}
}
// ----------------------------------------------------------------------------
type Quadruple[T1 any, T2 any, T3 any, T4 any] struct {
V1 T1
V2 T2
V3 T3
V4 T4
}
func (t Quadruple[T1, T2, T3, T4]) TupleLength() int {
return 4
}
func (t Quadruple[T1, T2, T3, T4]) TupleValues() []any {
return []any{t.V1, t.V2, t.V3, t.V4}
}
func NewQuadruple[T1 any, T2 any, T3 any, T4 any](v1 T1, v2 T2, v3 T3, v4 T4) Quadruple[T1, T2, T3, T4] {
return Quadruple[T1, T2, T3, T4]{V1: v1, V2: v2, V3: v3, V4: v4}
}
func NewTuple4[T1 any, T2 any, T3 any, T4 any](v1 T1, v2 T2, v3 T3, v4 T4) Quadruple[T1, T2, T3, T4] {
return Quadruple[T1, T2, T3, T4]{V1: v1, V2: v2, V3: v3, V4: v4}
}
// ----------------------------------------------------------------------------
type Quintuple[T1 any, T2 any, T3 any, T4 any, T5 any] struct {
V1 T1
V2 T2
V3 T3
V4 T4
V5 T5
}
func (t Quintuple[T1, T2, T3, T4, T5]) TupleLength() int {
return 5
}
func (t Quintuple[T1, T2, T3, T4, T5]) TupleValues() []any {
return []any{t.V1, t.V2, t.V3, t.V4, t.V5}
}
func NewQuintuple[T1 any, T2 any, T3 any, T4 any, T5 any](v1 T1, v2 T2, v3 T3, v4 T4, v5 T5) Quintuple[T1, T2, T3, T4, T5] {
return Quintuple[T1, T2, T3, T4, T5]{V1: v1, V2: v2, V3: v3, V4: v4, V5: v5}
}
func NewTuple5[T1 any, T2 any, T3 any, T4 any, T5 any](v1 T1, v2 T2, v3 T3, v4 T4, v5 T5) Quintuple[T1, T2, T3, T4, T5] {
return Quintuple[T1, T2, T3, T4, T5]{V1: v1, V2: v2, V3: v3, V4: v4, V5: v5}
}
// ----------------------------------------------------------------------------
type Sextuple[T1 any, T2 any, T3 any, T4 any, T5 any, T6 any] struct {
V1 T1
V2 T2
V3 T3
V4 T4
V5 T5
V6 T6
}
func (t Sextuple[T1, T2, T3, T4, T5, T6]) TupleLength() int {
return 6
}
func (t Sextuple[T1, T2, T3, T4, T5, T6]) TupleValues() []any {
return []any{t.V1, t.V2, t.V3, t.V4, t.V5, t.V6}
}
func NewSextuple[T1 any, T2 any, T3 any, T4 any, T5 any, T6 any](v1 T1, v2 T2, v3 T3, v4 T4, v5 T5, v6 T6) Sextuple[T1, T2, T3, T4, T5, T6] {
return Sextuple[T1, T2, T3, T4, T5, T6]{V1: v1, V2: v2, V3: v3, V4: v4, V5: v5, V6: v6}
}
func NewTuple6[T1 any, T2 any, T3 any, T4 any, T5 any, T6 any](v1 T1, v2 T2, v3 T3, v4 T4, v5 T5, v6 T6) Sextuple[T1, T2, T3, T4, T5, T6] {
return Sextuple[T1, T2, T3, T4, T5, T6]{V1: v1, V2: v2, V3: v3, V4: v4, V5: v5, V6: v6}
}
// ----------------------------------------------------------------------------
type Septuple[T1 any, T2 any, T3 any, T4 any, T5 any, T6 any, T7 any] struct {
V1 T1
V2 T2
V3 T3
V4 T4
V5 T5
V6 T6
V7 T7
}
func (t Septuple[T1, T2, T3, T4, T5, T6, T7]) TupleLength() int {
return 7
}
func (t Septuple[T1, T2, T3, T4, T5, T6, T7]) TupleValues() []any {
return []any{t.V1, t.V2, t.V3, t.V4, t.V5, t.V6, t.V7}
}
func NewSeptuple[T1 any, T2 any, T3 any, T4 any, T5 any, T6 any, T7 any](v1 T1, v2 T2, v3 T3, v4 T4, v5 T5, v6 T6, v7 T7) Septuple[T1, T2, T3, T4, T5, T6, T7] {
return Septuple[T1, T2, T3, T4, T5, T6, T7]{V1: v1, V2: v2, V3: v3, V4: v4, V5: v5, V6: v6, V7: v7}
}
func NewTuple7[T1 any, T2 any, T3 any, T4 any, T5 any, T6 any, T7 any](v1 T1, v2 T2, v3 T3, v4 T4, v5 T5, v6 T6, v7 T7) Septuple[T1, T2, T3, T4, T5, T6, T7] {
return Septuple[T1, T2, T3, T4, T5, T6, T7]{V1: v1, V2: v2, V3: v3, V4: v4, V5: v5, V6: v6, V7: v7}
}
// ----------------------------------------------------------------------------
type Octuple[T1 any, T2 any, T3 any, T4 any, T5 any, T6 any, T7 any, T8 any] struct {
V1 T1
V2 T2
V3 T3
V4 T4
V5 T5
V6 T6
V7 T7
V8 T8
}
func (t Octuple[T1, T2, T3, T4, T5, T6, T7, T8]) TupleLength() int {
return 8
}
func (t Octuple[T1, T2, T3, T4, T5, T6, T7, T8]) TupleValues() []any {
return []any{t.V1, t.V2, t.V3, t.V4, t.V5, t.V6, t.V7, t.V8}
}
func NewOctuple[T1 any, T2 any, T3 any, T4 any, T5 any, T6 any, T7 any, T8 any](v1 T1, v2 T2, v3 T3, v4 T4, v5 T5, v6 T6, v7 T7, v8 T8) Octuple[T1, T2, T3, T4, T5, T6, T7, T8] {
return Octuple[T1, T2, T3, T4, T5, T6, T7, T8]{V1: v1, V2: v2, V3: v3, V4: v4, V5: v5, V6: v6, V7: v7, V8: v8}
}
func NewTuple8[T1 any, T2 any, T3 any, T4 any, T5 any, T6 any, T7 any, T8 any](v1 T1, v2 T2, v3 T3, v4 T4, v5 T5, v6 T6, v7 T7, v8 T8) Octuple[T1, T2, T3, T4, T5, T6, T7, T8] {
return Octuple[T1, T2, T3, T4, T5, T6, T7, T8]{V1: v1, V2: v2, V3: v3, V4: v4, V5: v5, V6: v6, V7: v7, V8: v8}
}
// ----------------------------------------------------------------------------
type Nonuple[T1 any, T2 any, T3 any, T4 any, T5 any, T6 any, T7 any, T8 any, T9 any] struct {
V1 T1
V2 T2
V3 T3
V4 T4
V5 T5
V6 T6
V7 T7
V8 T8
V9 T9
}
func (t Nonuple[T1, T2, T3, T4, T5, T6, T7, T8, T9]) TupleLength() int {
return 9
}
func (t Nonuple[T1, T2, T3, T4, T5, T6, T7, T8, T9]) TupleValues() []any {
return []any{t.V1, t.V2, t.V3, t.V4, t.V5, t.V6, t.V7, t.V8, t.V9}
}
func NewNonuple[T1 any, T2 any, T3 any, T4 any, T5 any, T6 any, T7 any, T8 any, T9 any](v1 T1, v2 T2, v3 T3, v4 T4, v5 T5, v6 T6, v7 T7, v8 T8, v9 T9) Nonuple[T1, T2, T3, T4, T5, T6, T7, T8, T9] {
return Nonuple[T1, T2, T3, T4, T5, T6, T7, T8, T9]{V1: v1, V2: v2, V3: v3, V4: v4, V5: v5, V6: v6, V7: v7, V8: v8, V9: v9}
}
func NewTuple9[T1 any, T2 any, T3 any, T4 any, T5 any, T6 any, T7 any, T8 any, T9 any](v1 T1, v2 T2, v3 T3, v4 T4, v5 T5, v6 T6, v7 T7, v8 T8, v9 T9) Nonuple[T1, T2, T3, T4, T5, T6, T7, T8, T9] {
return Nonuple[T1, T2, T3, T4, T5, T6, T7, T8, T9]{V1: v1, V2: v2, V3: v3, V4: v4, V5: v5, V6: v6, V7: v7, V8: v8, V9: v9}
}
+136
View File
@@ -0,0 +1,136 @@
package dataext
import (
"reflect"
"testing"
)
func TestSingle(t *testing.T) {
s := NewSingle[int](7)
if s.V1 != 7 {
t.Fatalf("V1=%d", s.V1)
}
if s.TupleLength() != 1 {
t.Fatalf("len=%d", s.TupleLength())
}
if !reflect.DeepEqual(s.TupleValues(), []any{7}) {
t.Fatalf("values=%v", s.TupleValues())
}
if NewTuple1[int](7).V1 != 7 {
t.Fatal("NewTuple1 mismatch")
}
}
func TestTuple(t *testing.T) {
tp := NewTuple[int, string](1, "two")
if tp.V1 != 1 || tp.V2 != "two" {
t.Fatal("values wrong")
}
if tp.TupleLength() != 2 {
t.Fatal("len wrong")
}
if !reflect.DeepEqual(tp.TupleValues(), []any{1, "two"}) {
t.Fatalf("values=%v", tp.TupleValues())
}
if NewTuple2[int, string](1, "two") != tp {
t.Fatal("NewTuple2 mismatch")
}
}
func TestTriple(t *testing.T) {
tr := NewTriple[int, string, bool](1, "x", true)
if tr.TupleLength() != 3 {
t.Fatal("len wrong")
}
if !reflect.DeepEqual(tr.TupleValues(), []any{1, "x", true}) {
t.Fatalf("values=%v", tr.TupleValues())
}
if NewTuple3[int, string, bool](1, "x", true) != tr {
t.Fatal("NewTuple3 mismatch")
}
}
func TestQuadruple(t *testing.T) {
q := NewQuadruple[int, int, int, int](1, 2, 3, 4)
if q.TupleLength() != 4 {
t.Fatal("len wrong")
}
if !reflect.DeepEqual(q.TupleValues(), []any{1, 2, 3, 4}) {
t.Fatalf("values=%v", q.TupleValues())
}
if NewTuple4[int, int, int, int](1, 2, 3, 4) != q {
t.Fatal("NewTuple4 mismatch")
}
}
func TestQuintuple(t *testing.T) {
q := NewQuintuple[int, int, int, int, int](1, 2, 3, 4, 5)
if q.TupleLength() != 5 {
t.Fatal("len wrong")
}
if !reflect.DeepEqual(q.TupleValues(), []any{1, 2, 3, 4, 5}) {
t.Fatalf("values=%v", q.TupleValues())
}
if NewTuple5[int, int, int, int, int](1, 2, 3, 4, 5) != q {
t.Fatal("NewTuple5 mismatch")
}
}
func TestSextuple(t *testing.T) {
s := NewSextuple[int, int, int, int, int, int](1, 2, 3, 4, 5, 6)
if s.TupleLength() != 6 {
t.Fatal("len wrong")
}
if !reflect.DeepEqual(s.TupleValues(), []any{1, 2, 3, 4, 5, 6}) {
t.Fatalf("values=%v", s.TupleValues())
}
if NewTuple6[int, int, int, int, int, int](1, 2, 3, 4, 5, 6) != s {
t.Fatal("NewTuple6 mismatch")
}
}
func TestSeptuple(t *testing.T) {
s := NewSeptuple[int, int, int, int, int, int, int](1, 2, 3, 4, 5, 6, 7)
if s.TupleLength() != 7 {
t.Fatal("len wrong")
}
if !reflect.DeepEqual(s.TupleValues(), []any{1, 2, 3, 4, 5, 6, 7}) {
t.Fatalf("values=%v", s.TupleValues())
}
if NewTuple7[int, int, int, int, int, int, int](1, 2, 3, 4, 5, 6, 7) != s {
t.Fatal("NewTuple7 mismatch")
}
}
func TestOctuple(t *testing.T) {
o := NewOctuple[int, int, int, int, int, int, int, int](1, 2, 3, 4, 5, 6, 7, 8)
if o.TupleLength() != 8 {
t.Fatal("len wrong")
}
if !reflect.DeepEqual(o.TupleValues(), []any{1, 2, 3, 4, 5, 6, 7, 8}) {
t.Fatalf("values=%v", o.TupleValues())
}
if NewTuple8[int, int, int, int, int, int, int, int](1, 2, 3, 4, 5, 6, 7, 8) != o {
t.Fatal("NewTuple8 mismatch")
}
}
func TestNonuple(t *testing.T) {
n := NewNonuple[int, int, int, int, int, int, int, int, int](1, 2, 3, 4, 5, 6, 7, 8, 9)
if n.TupleLength() != 9 {
t.Fatal("len wrong")
}
if !reflect.DeepEqual(n.TupleValues(), []any{1, 2, 3, 4, 5, 6, 7, 8, 9}) {
t.Fatalf("values=%v", n.TupleValues())
}
if NewTuple9[int, int, int, int, int, int, int, int, int](1, 2, 3, 4, 5, 6, 7, 8, 9) != n {
t.Fatal("NewTuple9 mismatch")
}
}
func TestValueGroupInterface(t *testing.T) {
var vg ValueGroup = NewTuple[int, string](1, "a")
if vg.TupleLength() != 2 {
t.Fatal("interface length wrong")
}
}
+57
View File
@@ -0,0 +1,57 @@
package enums
import "maps"
import "encoding/json"
type Enum interface {
Valid() bool
ValuesAny() []any
ValuesMeta() []EnumMetaValue
VarName() string
TypeName() string
PackageName() string
}
type StringEnum interface {
Enum
String() string
}
type DescriptionEnum interface {
Enum
Description() string
DescriptionMeta() EnumDescriptionMetaValue
}
type EnumMetaValue struct {
VarName string `json:"varName"`
Value Enum `json:"value"`
Description *string `json:"description"`
}
type EnumDescriptionMetaValue struct {
VarName string `json:"varName"`
Value Enum `json:"value"`
Description string `json:"description"`
}
type EnumDataMetaValue struct {
VarName string `json:"varName"`
Value Enum `json:"value"`
Description *string `json:"description"`
Data map[string]any `json:"-"` //handled by MarshalJSON
}
func (v EnumDataMetaValue) MarshalJSON() ([]byte, error) {
m := make(map[string]any, 8)
maps.Copy(m, v.Data)
m["varName"] = v.VarName
m["value"] = v.Value
m["description"] = v.Description
return json.Marshal(m)
}
+258
View File
@@ -0,0 +1,258 @@
package enums
import (
"encoding/json"
"reflect"
"testing"
)
type mockEnum struct {
name string
}
func (m mockEnum) Valid() bool { return m.name != "" }
func (m mockEnum) ValuesAny() []any { return []any{mockEnum{name: "a"}, mockEnum{name: "b"}} }
func (m mockEnum) ValuesMeta() []EnumMetaValue { return nil }
func (m mockEnum) VarName() string { return m.name }
func (m mockEnum) TypeName() string { return "mockEnum" }
func (m mockEnum) PackageName() string { return "enums_test" }
func (m mockEnum) String() string { return "str:" + m.name }
func (m mockEnum) Description() string { return "desc:" + m.name }
func (m mockEnum) DescriptionMeta() EnumDescriptionMetaValue {
return EnumDescriptionMetaValue{VarName: m.name, Value: m, Description: "desc:" + m.name}
}
func (m mockEnum) MarshalJSON() ([]byte, error) {
return json.Marshal(m.name)
}
func TestMockEnumImplementsInterfaces(t *testing.T) {
var _ Enum = mockEnum{}
var _ StringEnum = mockEnum{}
var _ DescriptionEnum = mockEnum{}
}
func TestEnumValid(t *testing.T) {
if !(mockEnum{name: "x"}).Valid() {
t.Errorf("expected Valid() == true")
}
if (mockEnum{}).Valid() {
t.Errorf("expected Valid() == false for zero value")
}
}
func TestEnumMetaValueJSON(t *testing.T) {
desc := "the-description"
mv := EnumMetaValue{
VarName: "Foo",
Value: mockEnum{name: "foo"},
Description: &desc,
}
data, err := json.Marshal(mv)
if err != nil {
t.Fatalf("json.Marshal failed: %v", err)
}
var got map[string]any
if err := json.Unmarshal(data, &got); err != nil {
t.Fatalf("json.Unmarshal failed: %v", err)
}
if got["varName"] != "Foo" {
t.Errorf("varName == %v, want Foo", got["varName"])
}
if got["value"] != "foo" {
t.Errorf("value == %v, want foo", got["value"])
}
if got["description"] != "the-description" {
t.Errorf("description == %v, want the-description", got["description"])
}
}
func TestEnumMetaValueJSONNilDescription(t *testing.T) {
mv := EnumMetaValue{
VarName: "Foo",
Value: mockEnum{name: "foo"},
Description: nil,
}
data, err := json.Marshal(mv)
if err != nil {
t.Fatalf("json.Marshal failed: %v", err)
}
var got map[string]any
if err := json.Unmarshal(data, &got); err != nil {
t.Fatalf("json.Unmarshal failed: %v", err)
}
if got["description"] != nil {
t.Errorf("description == %v, want nil", got["description"])
}
}
func TestEnumDescriptionMetaValueJSON(t *testing.T) {
mv := EnumDescriptionMetaValue{
VarName: "Bar",
Value: mockEnum{name: "bar"},
Description: "bar-desc",
}
data, err := json.Marshal(mv)
if err != nil {
t.Fatalf("json.Marshal failed: %v", err)
}
var got map[string]any
if err := json.Unmarshal(data, &got); err != nil {
t.Fatalf("json.Unmarshal failed: %v", err)
}
expected := map[string]any{
"varName": "Bar",
"value": "bar",
"description": "bar-desc",
}
if !reflect.DeepEqual(got, expected) {
t.Errorf("json output == %v, want %v", got, expected)
}
}
func TestEnumDataMetaValueMarshalJSON(t *testing.T) {
desc := "data-desc"
mv := EnumDataMetaValue{
VarName: "Baz",
Value: mockEnum{name: "baz"},
Description: &desc,
Data: map[string]any{
"extra1": "hello",
"extra2": float64(42),
},
}
data, err := json.Marshal(mv)
if err != nil {
t.Fatalf("json.Marshal failed: %v", err)
}
var got map[string]any
if err := json.Unmarshal(data, &got); err != nil {
t.Fatalf("json.Unmarshal failed: %v", err)
}
if got["varName"] != "Baz" {
t.Errorf("varName == %v, want Baz", got["varName"])
}
if got["value"] != "baz" {
t.Errorf("value == %v, want baz", got["value"])
}
if got["description"] != "data-desc" {
t.Errorf("description == %v, want data-desc", got["description"])
}
if got["extra1"] != "hello" {
t.Errorf("extra1 == %v, want hello", got["extra1"])
}
if got["extra2"] != float64(42) {
t.Errorf("extra2 == %v, want 42", got["extra2"])
}
}
func TestEnumDataMetaValueMarshalJSONNilData(t *testing.T) {
mv := EnumDataMetaValue{
VarName: "Baz",
Value: mockEnum{name: "baz"},
Description: nil,
Data: nil,
}
data, err := json.Marshal(mv)
if err != nil {
t.Fatalf("json.Marshal failed: %v", err)
}
var got map[string]any
if err := json.Unmarshal(data, &got); err != nil {
t.Fatalf("json.Unmarshal failed: %v", err)
}
if got["varName"] != "Baz" {
t.Errorf("varName == %v, want Baz", got["varName"])
}
if got["value"] != "baz" {
t.Errorf("value == %v, want baz", got["value"])
}
if _, ok := got["description"]; !ok {
t.Errorf("description key missing in JSON output")
}
if got["description"] != nil {
t.Errorf("description == %v, want nil", got["description"])
}
if len(got) != 3 {
t.Errorf("expected 3 keys with nil Data, got %d: %v", len(got), got)
}
}
func TestEnumDataMetaValueMarshalJSONDataDoesNotOverrideStandardFields(t *testing.T) {
desc := "real-desc"
mv := EnumDataMetaValue{
VarName: "Real",
Value: mockEnum{name: "real"},
Description: &desc,
Data: map[string]any{
"varName": "ShouldBeOverwritten",
"value": "ShouldBeOverwritten",
"description": "ShouldBeOverwritten",
"keep": "kept",
},
}
data, err := json.Marshal(mv)
if err != nil {
t.Fatalf("json.Marshal failed: %v", err)
}
var got map[string]any
if err := json.Unmarshal(data, &got); err != nil {
t.Fatalf("json.Unmarshal failed: %v", err)
}
if got["varName"] != "Real" {
t.Errorf("varName == %v, want Real (standard field must override Data)", got["varName"])
}
if got["value"] != "real" {
t.Errorf("value == %v, want real (standard field must override Data)", got["value"])
}
if got["description"] != "real-desc" {
t.Errorf("description == %v, want real-desc (standard field must override Data)", got["description"])
}
if got["keep"] != "kept" {
t.Errorf("keep == %v, want kept", got["keep"])
}
}
func TestEnumDataMetaValueMarshalJSONEmptyData(t *testing.T) {
mv := EnumDataMetaValue{
VarName: "E",
Value: mockEnum{name: "e"},
Description: nil,
Data: map[string]any{},
}
data, err := json.Marshal(mv)
if err != nil {
t.Fatalf("json.Marshal failed: %v", err)
}
var got map[string]any
if err := json.Unmarshal(data, &got); err != nil {
t.Fatalf("json.Unmarshal failed: %v", err)
}
if got["varName"] != "E" {
t.Errorf("varName == %v, want E", got["varName"])
}
if got["value"] != "e" {
t.Errorf("value == %v, want e", got["value"])
}
}
+300
View File
@@ -0,0 +1,300 @@
package excelext
import (
"reflect"
"git.blackforestbytes.com/BlackForestBytes/goext/dataext"
"git.blackforestbytes.com/BlackForestBytes/goext/exerr"
"git.blackforestbytes.com/BlackForestBytes/goext/langext"
excelize360 "github.com/360EntSecGroup-Skylar/excelize"
"github.com/xuri/excelize/v2"
)
type excelMapperColDefinition[T any] struct {
style *int
header string
width *float64
fn func(T) (any, error)
}
type ExcelMapper[T any] struct {
StyleDate *int
StyleDatetime *int
StyleEUR *int
StylePercentage *int
StyleHeader *int
StyleWSHeader *int
SkipColumnHeader bool
sheetName string
wsHeader []dataext.Tuple[string, *int]
colDefinitions []excelMapperColDefinition[T]
colFilter []func(v T) bool
}
func NewExcelMapper[T any]() (*ExcelMapper[T], error) {
em := &ExcelMapper[T]{
StyleDate: nil,
StyleDatetime: nil,
StyleEUR: nil,
StylePercentage: nil,
StyleHeader: nil,
StyleWSHeader: nil,
sheetName: "",
SkipColumnHeader: false,
wsHeader: make([]dataext.Tuple[string, *int], 0),
colDefinitions: make([]excelMapperColDefinition[T], 0),
}
return em, nil
}
func (em *ExcelMapper[T]) InitNewFile(sheetName string) (*excelize.File, error) {
f := excelize.NewFile()
defSheet := f.GetSheetList()[0]
sheet1 := sheetName
sheetIdx, err := f.NewSheet(sheet1)
if err != nil {
return nil, err
}
f.SetActiveSheet(sheetIdx)
err = f.DeleteSheet(defSheet)
err = em.InitStyles(f)
if err != nil {
return nil, err
}
return f, nil
}
func (em *ExcelMapper[T]) InitStyles(f *excelize.File) error {
styleDate, err := f.NewStyle(&excelize.Style{
CustomNumFmt: new("dd.mm.yyyy"),
})
if err != nil {
return err
}
styleDatetime, err := f.NewStyle(&excelize.Style{
NumFmt: 22,
})
if err != nil {
return err
}
styleEUR, err := f.NewStyle(&excelize.Style{
NumFmt: 218,
})
if err != nil {
return err
}
stylePercentage, err := f.NewStyle(&excelize.Style{
NumFmt: 10,
})
if err != nil {
return err
}
styleHeader, err := f.NewStyle(&excelize.Style{
Font: &excelize.Font{Bold: true, Size: 11},
})
if err != nil {
return err
}
styleWSHeader, err := f.NewStyle(&excelize.Style{
Font: &excelize.Font{Bold: true, Size: 24},
})
if err != nil {
return err
}
em.StyleDate = &styleDate
em.StyleDatetime = &styleDatetime
em.StyleEUR = &styleEUR
em.StylePercentage = &stylePercentage
em.StyleHeader = &styleHeader
em.StyleWSHeader = &styleWSHeader
return nil
}
func (em *ExcelMapper[T]) AddWorksheetHeader(header string, style *int) {
em.wsHeader = append(em.wsHeader, dataext.NewTuple(header, style))
}
func (em *ExcelMapper[T]) AddColumn(header string, style *int, width *float64, fn func(T) any) {
em.colDefinitions = append(em.colDefinitions, excelMapperColDefinition[T]{
style: style,
header: header,
width: width,
fn: func(t T) (any, error) { return fn(t), nil },
})
}
func (em *ExcelMapper[T]) AddColumnErr(header string, style *int, width *float64, fn func(T) (any, error)) {
em.colDefinitions = append(em.colDefinitions, excelMapperColDefinition[T]{
style: style,
header: header,
width: width,
fn: fn,
})
}
func (em *ExcelMapper[T]) Build(sheetName string, data []T) ([]byte, error) {
f, err := em.InitNewFile(sheetName)
if err != nil {
return nil, exerr.Wrap(err, "failed to init new file").Build()
}
err = em.BuildSingleSheet(f, sheetName, data)
if err != nil {
return nil, exerr.Wrap(err, "").Build()
}
buffer, err := f.WriteToBuffer()
if err != nil {
return nil, exerr.Wrap(err, "failed to build xls").Build()
}
return buffer.Bytes(), nil
}
func (em *ExcelMapper[T]) BuildSingleSheet(f *excelize.File, sheetName string, data []T) error {
if em.StyleHeader == nil || em.StyleDate == nil || em.StyleDatetime == nil || em.StyleEUR == nil || em.StylePercentage == nil || em.StyleWSHeader == nil {
err := em.InitStyles(f)
if err != nil {
return exerr.Wrap(err, "failed to init styles").Build()
}
}
rowOffset := 0
if len(em.wsHeader) > 0 {
for range em.wsHeader {
rowOffset += 1
}
rowOffset += 1
}
if !em.SkipColumnHeader {
for i, col := range em.colDefinitions {
err := f.SetCellValue(sheetName, c(rowOffset+1, i), col.header)
if err != nil {
return err
}
}
}
for i, col := range em.colDefinitions {
if col.style != nil {
err := f.SetColStyle(sheetName, excelize360.ToAlphaString(i), *col.style)
if err != nil {
return err
}
}
}
for i, col := range em.colDefinitions {
if col.width != nil {
err := f.SetColWidth(sheetName, excelize360.ToAlphaString(i), excelize360.ToAlphaString(i), *col.width)
if err != nil {
return err
}
}
}
err := f.SetRowStyle(sheetName, rowOffset+1, rowOffset+1, *em.StyleHeader)
if err != nil {
return err
}
if len(em.wsHeader) > 0 {
for i, hdr := range em.wsHeader {
style := *langext.CoalesceOpt(hdr.V2, em.StyleWSHeader)
err = f.SetCellValue(sheetName, c(i+1, 0), hdr.V1)
if err != nil {
return err
}
err = f.MergeCell(sheetName, c(i+1, 0), c(i+1, len(em.colDefinitions)-1))
if err != nil {
return err
}
err = f.SetRowStyle(sheetName, 1, 1, style)
if err != nil {
return err
}
}
}
iRow := rowOffset + 1
if !em.SkipColumnHeader {
iRow += 1
}
for _, dat := range data {
skip := false
for _, filter := range em.colFilter {
if !filter(dat) {
skip = true
break
}
}
if skip {
continue
}
for iCol, col := range em.colDefinitions {
cellVal, err := col.fn(dat)
if err != nil {
return err
}
for reflect.ValueOf(cellVal).Kind() == reflect.Pointer && !reflect.ValueOf(cellVal).IsNil() {
cellVal = reflect.ValueOf(cellVal).Elem().Interface()
}
if langext.IsNil(cellVal) {
err = f.SetCellValue(sheetName, c(iRow, iCol), "")
if err != nil {
return err
}
} else {
err = f.SetCellValue(sheetName, c(iRow, iCol), cellVal)
if err != nil {
return err
}
}
}
iRow++
}
//for i, col := range em.colDefinitions {
// if col.width == nil {
// //TODO https://github.com/qax-os/excelize/pull/1386
// }
//}
return nil
}
func (em *ExcelMapper[T]) AddFilter(f func(v T) bool) {
em.colFilter = append(em.colFilter, f)
}
+303
View File
@@ -0,0 +1,303 @@
package excelext
import (
"bytes"
"errors"
"testing"
"git.blackforestbytes.com/BlackForestBytes/goext/tst"
"github.com/xuri/excelize/v2"
)
type testRow struct {
Name string
Age int
Score float64
}
func openBytes(t *testing.T, data []byte) *excelize.File {
t.Helper()
f, err := excelize.OpenReader(bytes.NewReader(data))
if err != nil {
t.Fatalf("failed to open xlsx bytes: %v", err)
}
return f
}
func cellValue(t *testing.T, f *excelize.File, sheet, axis string) string {
t.Helper()
v, err := f.GetCellValue(sheet, axis)
if err != nil {
t.Fatalf("GetCellValue(%s, %s) failed: %v", sheet, axis, err)
}
return v
}
func TestNewExcelMapper(t *testing.T) {
em, err := NewExcelMapper[testRow]()
tst.AssertNoErr(t, err)
if em == nil {
t.Fatal("expected non-nil mapper")
}
tst.AssertEqual(t, em.SkipColumnHeader, false)
tst.AssertEqual(t, len(em.colDefinitions), 0)
tst.AssertEqual(t, len(em.wsHeader), 0)
tst.AssertEqual(t, len(em.colFilter), 0)
if em.StyleDate != nil || em.StyleHeader != nil {
t.Errorf("expected styles to be nil before init")
}
}
func TestInitNewFileAndStyles(t *testing.T) {
em, _ := NewExcelMapper[testRow]()
f, err := em.InitNewFile("Sheet-Foo")
tst.AssertNoErr(t, err)
if f == nil {
t.Fatal("expected non-nil file")
}
sheets := f.GetSheetList()
tst.AssertEqual(t, len(sheets), 1)
tst.AssertEqual(t, sheets[0], "Sheet-Foo")
if em.StyleDate == nil || em.StyleDatetime == nil || em.StyleEUR == nil ||
em.StylePercentage == nil || em.StyleHeader == nil || em.StyleWSHeader == nil {
t.Errorf("expected all styles to be initialized")
}
}
func TestAddColumn(t *testing.T) {
em, _ := NewExcelMapper[testRow]()
em.AddColumn("Name", nil, nil, func(r testRow) any { return r.Name })
em.AddColumn("Age", nil, new(12.0), func(r testRow) any { return r.Age })
tst.AssertEqual(t, len(em.colDefinitions), 2)
tst.AssertEqual(t, em.colDefinitions[0].header, "Name")
tst.AssertEqual(t, em.colDefinitions[1].header, "Age")
if em.colDefinitions[1].width == nil || *em.colDefinitions[1].width != 12.0 {
t.Errorf("expected width 12.0")
}
val, err := em.colDefinitions[0].fn(testRow{Name: "Alice"})
tst.AssertNoErr(t, err)
tst.AssertEqual(t, val.(string), "Alice")
}
func TestAddColumnErr(t *testing.T) {
em, _ := NewExcelMapper[testRow]()
sentinel := errors.New("boom")
em.AddColumnErr("X", nil, nil, func(r testRow) (any, error) {
if r.Age < 0 {
return nil, sentinel
}
return r.Age, nil
})
tst.AssertEqual(t, len(em.colDefinitions), 1)
v, err := em.colDefinitions[0].fn(testRow{Age: 5})
tst.AssertNoErr(t, err)
tst.AssertEqual(t, v.(int), 5)
_, err = em.colDefinitions[0].fn(testRow{Age: -1})
if !errors.Is(err, sentinel) {
t.Errorf("expected sentinel error, got %v", err)
}
}
func TestAddWorksheetHeader(t *testing.T) {
em, _ := NewExcelMapper[testRow]()
em.AddWorksheetHeader("Title 1", nil)
em.AddWorksheetHeader("Title 2", new(7))
tst.AssertEqual(t, len(em.wsHeader), 2)
tst.AssertEqual(t, em.wsHeader[0].V1, "Title 1")
tst.AssertEqual(t, em.wsHeader[1].V1, "Title 2")
if em.wsHeader[1].V2 == nil || *em.wsHeader[1].V2 != 7 {
t.Errorf("expected style ptr 7")
}
if em.wsHeader[0].V2 != nil {
t.Errorf("expected nil style for first header")
}
}
func TestAddFilter(t *testing.T) {
em, _ := NewExcelMapper[testRow]()
em.AddFilter(func(v testRow) bool { return v.Age >= 18 })
em.AddFilter(func(v testRow) bool { return v.Score > 0 })
tst.AssertEqual(t, len(em.colFilter), 2)
}
func TestBuildBasic(t *testing.T) {
em, _ := NewExcelMapper[testRow]()
em.AddColumn("Name", nil, nil, func(r testRow) any { return r.Name })
em.AddColumn("Age", nil, nil, func(r testRow) any { return r.Age })
rows := []testRow{
{Name: "Alice", Age: 30},
{Name: "Bob", Age: 25},
}
data, err := em.Build("Sheet1", rows)
tst.AssertNoErr(t, err)
if len(data) == 0 {
t.Fatal("expected non-empty xlsx output")
}
f := openBytes(t, data)
defer f.Close()
tst.AssertEqual(t, cellValue(t, f, "Sheet1", "A1"), "Name")
tst.AssertEqual(t, cellValue(t, f, "Sheet1", "B1"), "Age")
tst.AssertEqual(t, cellValue(t, f, "Sheet1", "A2"), "Alice")
tst.AssertEqual(t, cellValue(t, f, "Sheet1", "B2"), "30")
tst.AssertEqual(t, cellValue(t, f, "Sheet1", "A3"), "Bob")
tst.AssertEqual(t, cellValue(t, f, "Sheet1", "B3"), "25")
}
func TestBuildSkipColumnHeader(t *testing.T) {
em, _ := NewExcelMapper[testRow]()
em.SkipColumnHeader = true
em.AddColumn("Name", nil, nil, func(r testRow) any { return r.Name })
rows := []testRow{{Name: "Alice"}, {Name: "Bob"}}
data, err := em.Build("Data", rows)
tst.AssertNoErr(t, err)
f := openBytes(t, data)
defer f.Close()
tst.AssertEqual(t, cellValue(t, f, "Data", "A1"), "Alice")
tst.AssertEqual(t, cellValue(t, f, "Data", "A2"), "Bob")
}
func TestBuildWithFilter(t *testing.T) {
em, _ := NewExcelMapper[testRow]()
em.AddColumn("Name", nil, nil, func(r testRow) any { return r.Name })
em.AddFilter(func(v testRow) bool { return v.Age >= 18 })
rows := []testRow{
{Name: "Alice", Age: 30},
{Name: "Charlie", Age: 12},
{Name: "Bob", Age: 25},
}
data, err := em.Build("S", rows)
tst.AssertNoErr(t, err)
f := openBytes(t, data)
defer f.Close()
tst.AssertEqual(t, cellValue(t, f, "S", "A1"), "Name")
tst.AssertEqual(t, cellValue(t, f, "S", "A2"), "Alice")
tst.AssertEqual(t, cellValue(t, f, "S", "A3"), "Bob")
tst.AssertEqual(t, cellValue(t, f, "S", "A4"), "")
}
func TestBuildWithWorksheetHeader(t *testing.T) {
em, _ := NewExcelMapper[testRow]()
em.AddWorksheetHeader("My Big Title", nil)
em.AddColumn("Name", nil, nil, func(r testRow) any { return r.Name })
em.AddColumn("Age", nil, nil, func(r testRow) any { return r.Age })
rows := []testRow{{Name: "Alice", Age: 30}}
data, err := em.Build("S", rows)
tst.AssertNoErr(t, err)
f := openBytes(t, data)
defer f.Close()
tst.AssertEqual(t, cellValue(t, f, "S", "A1"), "My Big Title")
tst.AssertEqual(t, cellValue(t, f, "S", "A3"), "Name")
tst.AssertEqual(t, cellValue(t, f, "S", "B3"), "Age")
tst.AssertEqual(t, cellValue(t, f, "S", "A4"), "Alice")
tst.AssertEqual(t, cellValue(t, f, "S", "B4"), "30")
}
func TestBuildHandlesNilPointer(t *testing.T) {
type ptrRow struct {
Name *string
}
em, _ := NewExcelMapper[ptrRow]()
em.AddColumn("Name", nil, nil, func(r ptrRow) any { return r.Name })
name := "Alice"
rows := []ptrRow{
{Name: &name},
{Name: nil},
}
data, err := em.Build("S", rows)
tst.AssertNoErr(t, err)
f := openBytes(t, data)
defer f.Close()
tst.AssertEqual(t, cellValue(t, f, "S", "A2"), "Alice")
tst.AssertEqual(t, cellValue(t, f, "S", "A3"), "")
}
func TestBuildPropagatesColumnError(t *testing.T) {
em, _ := NewExcelMapper[testRow]()
sentinel := errors.New("col fail")
em.AddColumnErr("Bad", nil, nil, func(r testRow) (any, error) {
return nil, sentinel
})
_, err := em.Build("S", []testRow{{Name: "X"}})
if err == nil {
t.Fatal("expected error from column fn to propagate")
}
}
func TestBuildEmptyData(t *testing.T) {
em, _ := NewExcelMapper[testRow]()
em.AddColumn("Name", nil, nil, func(r testRow) any { return r.Name })
data, err := em.Build("S", []testRow{})
tst.AssertNoErr(t, err)
f := openBytes(t, data)
defer f.Close()
tst.AssertEqual(t, cellValue(t, f, "S", "A1"), "Name")
tst.AssertEqual(t, cellValue(t, f, "S", "A2"), "")
}
func TestBuildSingleSheetWithExistingFile(t *testing.T) {
em, _ := NewExcelMapper[testRow]()
em.AddColumn("Name", nil, nil, func(r testRow) any { return r.Name })
f, err := em.InitNewFile("S1")
tst.AssertNoErr(t, err)
_, err = f.NewSheet("S2")
tst.AssertNoErr(t, err)
err = em.BuildSingleSheet(f, "S2", []testRow{{Name: "Bob"}})
tst.AssertNoErr(t, err)
tst.AssertEqual(t, cellValue(t, f, "S2", "A1"), "Name")
tst.AssertEqual(t, cellValue(t, f, "S2", "A2"), "Bob")
}
func TestBuildWithColumnWidthAndStyle(t *testing.T) {
em, _ := NewExcelMapper[testRow]()
f, err := em.InitNewFile("S")
tst.AssertNoErr(t, err)
em.AddColumn("Name", em.StyleHeader, new(20.5), func(r testRow) any { return r.Name })
err = em.BuildSingleSheet(f, "S", []testRow{{Name: "Alice"}})
tst.AssertNoErr(t, err)
w, err := f.GetColWidth("S", "A")
tst.AssertNoErr(t, err)
if w < 20.0 || w > 21.0 {
t.Errorf("expected column width near 20.5, got %v", w)
}
}
+26
View File
@@ -0,0 +1,26 @@
package excelext
import (
"strconv"
"git.blackforestbytes.com/BlackForestBytes/goext/rfctime"
"github.com/360EntSecGroup-Skylar/excelize"
)
func c(row int, col int) string {
return excelize.ToAlphaString(col) + strconv.Itoa(row)
}
func excelizeOptTime(t *rfctime.RFC3339NanoTime) any {
if t == nil {
return ""
}
return t.Time()
}
func excelizeOptDate(t *rfctime.Date) any {
if t == nil {
return ""
}
return t.TimeUTC()
}
+61
View File
@@ -0,0 +1,61 @@
package excelext
import (
"testing"
"time"
"git.blackforestbytes.com/BlackForestBytes/goext/rfctime"
"git.blackforestbytes.com/BlackForestBytes/goext/tst"
)
func TestCellAddress(t *testing.T) {
tst.AssertEqual(t, c(1, 0), "A1")
tst.AssertEqual(t, c(1, 1), "B1")
tst.AssertEqual(t, c(2, 0), "A2")
tst.AssertEqual(t, c(10, 25), "Z10")
tst.AssertEqual(t, c(1, 26), "AA1")
tst.AssertEqual(t, c(99, 27), "AB99")
tst.AssertEqual(t, c(100, 51), "AZ100")
tst.AssertEqual(t, c(1, 52), "BA1")
}
func TestExcelizeOptTimeNil(t *testing.T) {
got := excelizeOptTime(nil)
if got != "" {
t.Errorf("expected empty string for nil time, got %v", got)
}
}
func TestExcelizeOptTimeValue(t *testing.T) {
now := time.Date(2024, 5, 17, 13, 45, 30, 0, time.UTC)
rt := rfctime.RFC3339NanoTime(now)
got := excelizeOptTime(&rt)
gt, ok := got.(time.Time)
if !ok {
t.Fatalf("expected time.Time, got %T", got)
}
if !gt.Equal(now) {
t.Errorf("expected %v, got %v", now, gt)
}
}
func TestExcelizeOptDateNil(t *testing.T) {
got := excelizeOptDate(nil)
if got != "" {
t.Errorf("expected empty string for nil date, got %v", got)
}
}
func TestExcelizeOptDateValue(t *testing.T) {
d := rfctime.NewDate(time.Date(2025, 11, 3, 0, 0, 0, 0, time.UTC))
got := excelizeOptDate(&d)
gt, ok := got.(time.Time)
if !ok {
t.Fatalf("expected time.Time, got %T", got)
}
if gt.Year() != 2025 || gt.Month() != 11 || gt.Day() != 3 {
t.Errorf("unexpected date returned: %v", gt)
}
}
+572
View File
@@ -0,0 +1,572 @@
package exerr
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"os"
"runtime/debug"
"strings"
"time"
"git.blackforestbytes.com/BlackForestBytes/goext/dataext"
"git.blackforestbytes.com/BlackForestBytes/goext/enums"
"git.blackforestbytes.com/BlackForestBytes/goext/langext"
"github.com/gin-gonic/gin"
"github.com/rs/zerolog"
"go.mongodb.org/mongo-driver/v2/bson"
)
//
// ==== USAGE =====
//
// If some method returns an error _always wrap it into an exerror:
// value, err := do_something(..)
// if err != nil {
// return nil, exerror.Wrap(err, "do something failed").Build()
// }
//
// If possible add metadata to the error (eg the id that was not found, ...), the methods are the same as in zerolog
// return nil, exerror.Wrap(err, "do something failed").Str("someid", id).Int("count", in.Count).Build()
//
// You can also add extra-data to an error with Extra(..)
// in contrast to metadata is extradata always printed in the resulting error and is more intended for additional (programmatically readable) data in addition to the errortype
// (metadata is more internal debug info/help)
//
// You can change the errortype with `.User()` and `.System()` (User-errors are 400 and System-errors 500)
// You can also manually set the statuscode with `.WithStatuscode(http.NotFound)`
// You can set the type with `WithType(..)`
//
// New Errors (that don't wrap an existing err object) are created with New
// return nil, exerror.New(exerror.TypeInternal, "womethign wen horrible wrong").Build()
// You can eitehr use an existing ErrorType, the "catch-all" ErrInternal, or add you own ErrType in consts.go
//
// All errors should be handled one of the following four ways:
// - return the error to the caller and let him handle it:
// (also auto-prints the error to the log)
// => Wrap/New + Build
// - Print the error
// (also auto-sends it to the error-service)
// This is useful for errors that happen asynchron or are non-fatal for the current request
// => Wrap/New + Print
// - Return the error to the Rest-API caller
// (also auto-prints the error to the log)
// (also auto-sends it to the error-service)
// => Wrap/New + Output
// - Print and stop the service
// (also auto-sends it to the error-service)
// => Wrap/New + Fatal
//
type Builder struct {
wrappedErr error
errorData *ExErr
containsGinData bool
containsContextData bool
noLog bool
}
func Get(err error) *Builder {
return &Builder{errorData: FromError(err)}
}
func New(t ErrorType, msg string) *Builder {
return &Builder{errorData: newExErr(CatSystem, t, msg)}
}
func Wrap(err error, msg string) *Builder {
if err == nil {
return &Builder{errorData: newExErr(CatSystem, TypeInternal, msg)} // prevent NPE if we call Wrap with err==nil
}
v := FromError(err)
if !pkgconfig.RecursiveErrors {
v.Message = msg
return &Builder{wrappedErr: err, errorData: v}
} else {
return &Builder{wrappedErr: err, errorData: wrapExErr(v, msg, CatWrap, 1)}
}
}
// ----------------------------------------------------------------------------
func (b *Builder) WithType(t ErrorType) *Builder {
b.errorData.Type = t
return b
}
func (b *Builder) WithStatuscode(status int) *Builder {
b.errorData.StatusCode = &status
return b
}
func (b *Builder) WithMessage(msg string) *Builder {
b.errorData.Message = msg
return b
}
func (b *Builder) WithSeverity(v ErrorSeverity) *Builder {
b.errorData.Severity = v
return b
}
func (b *Builder) WithCategory(v ErrorCategory) *Builder {
b.errorData.Category = v
return b
}
// ----------------------------------------------------------------------------
// Err changes the Severity to ERROR (default)
// The error will be:
//
// - On Build():
//
// - Short-Logged as Err
//
// - On Print():
//
// - Logged as Err
//
// - Send to the error-service
//
// - On Output():
//
// - Logged as Err
//
// - Send to the error-service
func (b *Builder) Err() *Builder {
b.errorData.Severity = SevErr
return b
}
// Warn changes the Severity to WARN
// The error will be:
//
// - On Build():
//
// - -(nothing)-
//
// - On Print():
//
// - Short-Logged as Warn
//
// - On Output():
//
// - Logged as Warn
func (b *Builder) Warn() *Builder {
b.errorData.Severity = SevWarn
return b
}
// Info changes the Severity to INFO
// The error will be:
//
// - On Build():
//
// - -(nothing)-
//
// - On Print():
//
// - -(nothing)-
//
// - On Output():
//
// - -(nothing)-
func (b *Builder) Info() *Builder {
b.errorData.Severity = SevInfo
return b
}
// ----------------------------------------------------------------------------
// User sets the Category to CatUser
//
// Errors with category
func (b *Builder) User() *Builder {
b.errorData.Category = CatUser
return b
}
func (b *Builder) System() *Builder {
b.errorData.Category = CatSystem
return b
}
// ----------------------------------------------------------------------------
func (b *Builder) NoLog() *Builder {
b.noLog = true
return b
}
// ----------------------------------------------------------------------------
func (b *Builder) Id(key string, val fmt.Stringer) *Builder {
return b.addMeta(key, MDTID, newIDWrap(val))
}
func (b *Builder) StrPtr(key string, val *string) *Builder {
return b.addMeta(key, MDTStringPtr, val)
}
func (b *Builder) Str(key string, val string) *Builder {
return b.addMeta(key, MDTString, val)
}
func (b *Builder) Int(key string, val int) *Builder {
return b.addMeta(key, MDTInt, val)
}
func (b *Builder) Int8(key string, val int8) *Builder {
return b.addMeta(key, MDTInt8, val)
}
func (b *Builder) Int16(key string, val int16) *Builder {
return b.addMeta(key, MDTInt16, val)
}
func (b *Builder) Int32(key string, val int32) *Builder {
return b.addMeta(key, MDTInt32, val)
}
func (b *Builder) Int64(key string, val int64) *Builder {
return b.addMeta(key, MDTInt64, val)
}
func (b *Builder) Float32(key string, val float32) *Builder {
return b.addMeta(key, MDTFloat32, val)
}
func (b *Builder) Float64(key string, val float64) *Builder {
return b.addMeta(key, MDTFloat64, val)
}
func (b *Builder) Bool(key string, val bool) *Builder {
return b.addMeta(key, MDTBool, val)
}
func (b *Builder) Bytes(key string, val []byte) *Builder {
return b.addMeta(key, MDTBytes, val)
}
func (b *Builder) ObjectID(key string, val bson.ObjectID) *Builder {
return b.addMeta(key, MDTObjectID, val)
}
func (b *Builder) Time(key string, val time.Time) *Builder {
return b.addMeta(key, MDTTime, val)
}
func (b *Builder) Dur(key string, val time.Duration) *Builder {
return b.addMeta(key, MDTDuration, val)
}
func (b *Builder) Strs(key string, val []string) *Builder {
return b.addMeta(key, MDTStringArray, val)
}
func (b *Builder) Ints(key string, val []int) *Builder {
return b.addMeta(key, MDTIntArray, val)
}
func (b *Builder) Ints32(key string, val []int32) *Builder {
return b.addMeta(key, MDTInt32Array, val)
}
func (b *Builder) Type(key string, cls any) *Builder {
return b.addMeta(key, MDTString, fmt.Sprintf("%T", cls))
}
func (b *Builder) Interface(key string, val any) *Builder {
return b.addMeta(key, MDTAny, newAnyWrap(val))
}
func (b *Builder) Any(key string, val any) *Builder {
return b.addMeta(key, MDTAny, newAnyWrap(val))
}
func (b *Builder) Stringer(key string, val fmt.Stringer) *Builder {
if langext.IsNil(val) {
return b.addMeta(key, MDTString, "(!nil)")
} else {
return b.addMeta(key, MDTString, val.String())
}
}
func (b *Builder) Enum(key string, val enums.Enum) *Builder {
return b.addMeta(key, MDTEnum, newEnumWrap(val))
}
func (b *Builder) Stack() *Builder {
return b.addMeta("@Stack", MDTString, string(debug.Stack()))
}
func (b *Builder) Errs(key string, val []error) *Builder {
for i, valerr := range val {
b.addMeta(fmt.Sprintf("%v[%v]", key, i), MDTString, Get(valerr).errorData.FormatLog(LogPrintFull))
}
return b
}
func (b *Builder) GinReq(ctx context.Context, g *gin.Context, req *http.Request) *Builder {
if v := ctx.Value("start_timestamp"); v != nil {
if t, ok := v.(time.Time); ok {
b.Time("ctx_startTimestamp", t)
b.Time("ctx_endTimestamp", time.Now())
}
}
b.Str("gin_method", req.Method)
b.Str("gin_host", req.Host)
b.Str("gin_path", g.FullPath())
b.Strs("gin_header", extractHeader(g.Request.Header))
if req.URL != nil {
b.Str("gin_url", req.URL.String())
}
if ctxVal := g.GetString("apiversion"); ctxVal != "" {
b.Str("gin_context_apiversion", ctxVal)
}
if ctxVal := g.GetString("uid"); ctxVal != "" {
b.Str("gin_context_uid", ctxVal)
}
if ctxVal := g.GetString("fcmId"); ctxVal != "" {
b.Str("gin_context_fcmid", ctxVal)
}
if ctxVal := g.GetString("reqid"); ctxVal != "" {
b.Str("gin_context_reqid", ctxVal)
}
if req.Method != "GET" && req.Body != nil {
if req.Header.Get("Content-Type") == "application/json" {
if brc, ok := req.Body.(dataext.BufferedReadCloser); ok {
if bin, err := brc.BufferedAll(); err == nil {
if len(bin) < 16*1024 {
var prettyJSON bytes.Buffer
err = json.Indent(&prettyJSON, bin, "", " ")
if err == nil {
b.Str("gin_body", string(prettyJSON.Bytes()))
} else {
b.Bytes("gin_body", bin)
}
} else {
b.Str("gin_body", fmt.Sprintf("[[%v bytes | %s]]", len(bin), req.Header.Get("Content-Type")))
}
}
}
}
if req.Header.Get("Content-Type") == "multipart/form-data" || req.Header.Get("Content-Type") == "x-www-form-urlencoded" {
if brc, ok := req.Body.(dataext.BufferedReadCloser); ok {
if bin, err := brc.BufferedAll(); err == nil {
if len(bin) < 16*1024 {
b.Bytes("gin_body", bin)
} else {
b.Str("gin_body", fmt.Sprintf("[[%v bytes | %s]]", len(bin), req.Header.Get("Content-Type")))
}
}
}
}
}
pkgconfig.ExtendGinMeta(ctx, b, g, req)
b.containsGinData = true
return b
}
func (b *Builder) CtxData(method Method, ctx context.Context) *Builder {
pkgconfig.ExtendContextMeta(b, method, ctx)
b.containsContextData = true
return b
}
func extractHeader(header map[string][]string) []string {
r := make([]string, 0, len(header))
for k, v := range header {
for _, hval := range v {
value := hval
value = strings.ReplaceAll(value, "\n", "\\n")
value = strings.ReplaceAll(value, "\r", "\\r")
value = strings.ReplaceAll(value, "\t", "\\t")
r = append(r, k+": "+value)
}
}
return r
}
// ----------------------------------------------------------------------------
// Extra adds additional data to the error
// this is not like the other metadata (like Id(), Str(), etc)
// this data is public and will be printed/outputted
func (b *Builder) Extra(key string, val any) *Builder {
b.errorData.Extra[key] = val
return b
}
// ----------------------------------------------------------------------------
// Build creates a new error, ready to pass up the stack
// If the errors is not SevWarn or SevInfo it gets also logged (in short form, without stacktrace) onto stdout
// Can be gloablly configured with ZeroLogErrTraces and ZeroLogAllTraces
// Can be locally suppressed with Builder.NoLog()
func (b *Builder) Build(ctxs ...context.Context) error {
return b.BuildAsExerr(ctxs...)
}
func (b *Builder) BuildAsExerr(ctxs ...context.Context) *ExErr {
warnOnPkgConfigNotInitialized()
for _, dctx := range ctxs {
b.CtxData(MethodBuild, dctx)
}
if pkgconfig.DisableErrorWrapping && b.wrappedErr != nil {
return FromError(b.wrappedErr)
}
if pkgconfig.ZeroLogErrTraces && !b.noLog {
if b.errorData.Severity == SevErr || b.errorData.Severity == SevFatal {
b.errorData.ShortLog(pkgconfig.ZeroLogger.Error())
} else if b.errorData.Severity == SevWarn {
b.errorData.ShortLog(pkgconfig.ZeroLogger.Warn())
} else if b.errorData.Severity == SevInfo {
b.errorData.ShortLog(pkgconfig.ZeroLogger.Info())
} else if b.errorData.Severity == SevDebug {
b.errorData.ShortLog(pkgconfig.ZeroLogger.Debug())
} else if b.errorData.Severity == SevTrace {
b.errorData.ShortLog(pkgconfig.ZeroLogger.Trace())
} else {
b.errorData.ShortLog(pkgconfig.ZeroLogger.Error()) // ?!? unknown severity
}
}
b.errorData.CallListener(MethodBuild, ListenerOpt{NoLog: b.noLog})
return b.errorData
}
// Output prints the error onto the gin stdout.
// The error also gets printed to stdout/stderr
// If the error is SevErr|SevFatal we also send it to the error-service
func (b *Builder) Output(ctx context.Context, g *gin.Context) {
warnOnPkgConfigNotInitialized()
if !b.containsGinData && g.Request != nil {
// Auto-Add gin metadata if the caller hasn't already done it
b.GinReq(ctx, g, g.Request)
}
b.CtxData(MethodOutput, ctx)
// this is only here to add one level to the trace
// so that .Build() and .Output() and .Print() have the same depth and our stack-skip logger can have the same skip-count
b.doGinOutput(ctx, g)
}
// OutputRaw works teh same as Output() - but does not depend on gin and works with a raw http.ResponseWriter
func (b *Builder) OutputRaw(w http.ResponseWriter) {
warnOnPkgConfigNotInitialized()
// this is only here to add one level to the trace
// so that .Build() and .Output() and .Print() have the same depth and our stack-skip logger can have the same skip-count
b.doRawOutput(w)
}
func (b *Builder) doGinOutput(ctx context.Context, g *gin.Context) {
b.errorData.Output(g)
if (b.errorData.Severity == SevErr || b.errorData.Severity == SevFatal) && (pkgconfig.ZeroLogErrGinOutput || pkgconfig.ZeroLogAllGinOutput) {
b.errorData.Log(pkgconfig.ZeroLogger.Error())
} else if (b.errorData.Severity == SevWarn) && (pkgconfig.ZeroLogAllGinOutput) {
b.errorData.Log(pkgconfig.ZeroLogger.Warn())
}
b.errorData.CallListener(MethodOutput, ListenerOpt{NoLog: b.noLog})
}
func (b *Builder) doRawOutput(w http.ResponseWriter) {
b.errorData.OutputRaw(w)
if (b.errorData.Severity == SevErr || b.errorData.Severity == SevFatal) && (pkgconfig.ZeroLogErrGinOutput || pkgconfig.ZeroLogAllGinOutput) {
b.errorData.Log(pkgconfig.ZeroLogger.Error())
} else if (b.errorData.Severity == SevWarn) && (pkgconfig.ZeroLogAllGinOutput) {
b.errorData.Log(pkgconfig.ZeroLogger.Warn())
}
b.errorData.CallListener(MethodOutput, ListenerOpt{NoLog: b.noLog})
}
// Print prints the error
// If the error is SevErr we also send it to the error-service
func (b *Builder) Print(ctxs ...context.Context) Proxy {
warnOnPkgConfigNotInitialized()
for _, dctx := range ctxs {
b.CtxData(MethodPrint, dctx)
}
// this is only here to add one level to the trace
// so that .Build() and .Output() and .Print() have the same depth and our stack-skip logger can have the same skip-count
return b.doPrint()
}
func (b *Builder) doPrint() Proxy {
if b.errorData.Severity == SevErr || b.errorData.Severity == SevFatal {
b.errorData.Log(pkgconfig.ZeroLogger.Error())
} else if b.errorData.Severity == SevWarn {
b.errorData.ShortLog(pkgconfig.ZeroLogger.Warn())
} else if b.errorData.Severity == SevInfo {
b.errorData.ShortLog(pkgconfig.ZeroLogger.Info())
} else if b.errorData.Severity == SevDebug {
b.errorData.ShortLog(pkgconfig.ZeroLogger.Debug())
} else if b.errorData.Severity == SevTrace {
b.errorData.ShortLog(pkgconfig.ZeroLogger.Trace())
} else {
b.errorData.Log(pkgconfig.ZeroLogger.Error()) // ?!? unknown severity
}
b.errorData.CallListener(MethodPrint, ListenerOpt{NoLog: b.noLog})
return Proxy{v: *b.errorData} // we return Proxy<Exerr> here instead of Exerr to prevent warnings on ignored err-returns
}
func (b *Builder) Format(level LogPrintLevel) string {
return b.errorData.FormatLog(level)
}
// Fatal prints the error and terminates the program
// If the error is SevErr we also send it to the error-service
func (b *Builder) Fatal(ctxs ...context.Context) {
b.errorData.Severity = SevFatal
for _, dctx := range ctxs {
b.CtxData(MethodFatal, dctx)
}
// this is only here to add one level to the trace
// so that .Build() and .Output() and .Print() have the same depth and our stack-skip logger can have the same skip-count
b.doLogFatal()
b.errorData.CallListener(MethodFatal, ListenerOpt{NoLog: b.noLog})
os.Exit(1)
}
func (b *Builder) doLogFatal() {
b.errorData.Log(pkgconfig.ZeroLogger.WithLevel(zerolog.FatalLevel))
}
// ----------------------------------------------------------------------------
func (b *Builder) addMeta(key string, mdtype metaDataType, val any) *Builder {
b.errorData.Meta.add(key, mdtype, val)
return b
}
+272
View File
@@ -0,0 +1,272 @@
package exerr
import (
"encoding/json"
"fmt"
"maps"
"reflect"
"time"
"git.blackforestbytes.com/BlackForestBytes/goext/langext"
"go.mongodb.org/mongo-driver/v2/bson"
)
var reflectTypeStr = reflect.TypeFor[string]()
func FromError(err error) *ExErr {
if err == nil {
// prevent NPE if we call FromError with err==nil
return &ExErr{
UniqueID: newID(),
Category: CatForeign,
Type: TypeInternal,
Severity: SevErr,
Timestamp: time.Time{},
StatusCode: nil,
Message: "",
WrappedErrType: "nil",
WrappedErr: err,
Caller: "",
OriginalError: nil,
Meta: make(MetaMap),
Extra: make(map[string]any),
}
}
//goland:noinspection GoTypeAssertionOnErrors
if verr, ok := err.(*ExErr); ok {
// A simple ExErr
return verr
}
//goland:noinspection GoTypeAssertionOnErrors
if verr, ok := err.(langext.PanicWrappedErr); ok {
return &ExErr{
UniqueID: newID(),
Category: CatForeign,
Type: TypePanic,
Severity: SevErr,
Timestamp: time.Time{},
StatusCode: nil,
Message: "A panic occured",
WrappedErrType: fmt.Sprintf("%T", verr),
WrappedErr: err,
Caller: "",
OriginalError: nil,
Meta: MetaMap{
"panic_object": {DataType: MDTString, Value: fmt.Sprintf("%+v", verr.RecoveredObj())},
"panic_type": {DataType: MDTString, Value: fmt.Sprintf("%T", verr.RecoveredObj())},
"stack": {DataType: MDTString, Value: verr.Stack},
},
Extra: make(map[string]any),
}
}
//goland:noinspection GoTypeAssertionOnErrors
if verr, ok := err.(*langext.PanicWrappedErr); ok && verr != nil {
return &ExErr{
UniqueID: newID(),
Category: CatForeign,
Type: TypePanic,
Severity: SevErr,
Timestamp: time.Time{},
StatusCode: nil,
Message: "A panic occured",
WrappedErrType: fmt.Sprintf("%T", verr),
WrappedErr: err,
Caller: "",
OriginalError: nil,
Meta: MetaMap{
"panic_object": {DataType: MDTString, Value: fmt.Sprintf("%+v", verr.RecoveredObj())},
"panic_type": {DataType: MDTString, Value: fmt.Sprintf("%T", verr.RecoveredObj())},
"stack": {DataType: MDTString, Value: verr.Stack},
},
Extra: make(map[string]any),
}
}
// A foreign error (eg a MongoDB exception)
return &ExErr{
UniqueID: newID(),
Category: CatForeign,
Type: TypeInternal,
Severity: SevErr,
Timestamp: time.Time{},
StatusCode: nil,
Message: err.Error(),
WrappedErrType: fmt.Sprintf("%T", err),
WrappedErr: err,
Caller: "",
OriginalError: nil,
Meta: getForeignMeta(err),
Extra: make(map[string]any),
}
}
func newExErr(cat ErrorCategory, errtype ErrorType, msg string) *ExErr {
return &ExErr{
UniqueID: newID(),
Category: cat,
Type: errtype,
Severity: SevErr,
Timestamp: time.Now(),
StatusCode: nil,
Message: msg,
WrappedErrType: "",
WrappedErr: nil,
Caller: callername(2),
OriginalError: nil,
Meta: make(map[string]MetaValue),
Extra: make(map[string]any),
}
}
func wrapExErr(e *ExErr, msg string, cat ErrorCategory, stacktraceskip int) *ExErr {
return &ExErr{
UniqueID: newID(),
Category: cat,
Type: TypeWrap,
Severity: e.Severity,
Timestamp: time.Now(),
StatusCode: e.StatusCode,
Message: msg,
WrappedErrType: "",
WrappedErr: nil,
Caller: callername(1 + stacktraceskip),
OriginalError: e,
Meta: make(map[string]MetaValue),
Extra: langext.CopyMap(langext.ForceMap(e.Extra)),
}
}
func getForeignMeta(err error) (mm MetaMap) {
mm = make(map[string]MetaValue)
defer func() {
if panicerr := recover(); panicerr != nil {
New(TypePanic, "Panic while trying to get foreign meta").
Str("source", err.Error()).
Interface("panic-object", panicerr).
Stack().
Print()
}
}()
rval := reflect.ValueOf(err)
if rval.Kind() == reflect.Interface || rval.Kind() == reflect.Pointer {
rval = reflect.ValueOf(err).Elem()
}
mm.add("foreign.errortype", MDTString, rval.Type().String())
maps.Copy(mm, addMetaPrefix("foreign", getReflectedMetaValues(err, 8)))
return mm
}
func getReflectedMetaValues(value any, remainingDepth int) map[string]MetaValue {
if remainingDepth <= 0 {
return map[string]MetaValue{}
}
if langext.IsNil(value) {
return map[string]MetaValue{"": {DataType: MDTNil, Value: nil}}
}
rval := reflect.ValueOf(value)
if rval.Type().Kind() == reflect.Pointer {
if rval.IsNil() {
return map[string]MetaValue{"*": {DataType: MDTNil, Value: nil}}
}
elem := rval.Elem()
return addMetaPrefix("*", getReflectedMetaValues(elem.Interface(), remainingDepth-1))
}
if !rval.CanInterface() {
return map[string]MetaValue{"": {DataType: MDTString, Value: "<<no-interface>>"}}
}
raw := rval.Interface()
switch ifraw := raw.(type) {
case time.Time:
return map[string]MetaValue{"": {DataType: MDTTime, Value: ifraw}}
case time.Duration:
return map[string]MetaValue{"": {DataType: MDTDuration, Value: ifraw}}
case int:
return map[string]MetaValue{"": {DataType: MDTInt, Value: ifraw}}
case int8:
return map[string]MetaValue{"": {DataType: MDTInt8, Value: ifraw}}
case int16:
return map[string]MetaValue{"": {DataType: MDTInt16, Value: ifraw}}
case int32:
return map[string]MetaValue{"": {DataType: MDTInt32, Value: ifraw}}
case int64:
return map[string]MetaValue{"": {DataType: MDTInt64, Value: ifraw}}
case string:
return map[string]MetaValue{"": {DataType: MDTString, Value: ifraw}}
case bool:
return map[string]MetaValue{"": {DataType: MDTBool, Value: ifraw}}
case []byte:
return map[string]MetaValue{"": {DataType: MDTBytes, Value: ifraw}}
case float32:
return map[string]MetaValue{"": {DataType: MDTFloat32, Value: ifraw}}
case float64:
return map[string]MetaValue{"": {DataType: MDTFloat64, Value: ifraw}}
case []int:
return map[string]MetaValue{"": {DataType: MDTIntArray, Value: ifraw}}
case []int32:
return map[string]MetaValue{"": {DataType: MDTInt32Array, Value: ifraw}}
case bson.ObjectID:
return map[string]MetaValue{"": {DataType: MDTObjectID, Value: ifraw}}
case []string:
return map[string]MetaValue{"": {DataType: MDTStringArray, Value: ifraw}}
}
if rval.Type().Kind() == reflect.Struct {
m := make(map[string]MetaValue)
for i := 0; i < rval.NumField(); i++ {
fieldtype := rval.Type().Field(i)
fieldname := fieldtype.Name
if fieldtype.IsExported() {
maps.Copy(m, addMetaPrefix(fieldname, getReflectedMetaValues(rval.Field(i).Interface(), remainingDepth-1)))
}
}
return m
}
if rval.Type().ConvertibleTo(reflectTypeStr) {
return map[string]MetaValue{"": {DataType: MDTString, Value: rval.Convert(reflectTypeStr).String()}}
}
jsonval, err := json.Marshal(value)
if err != nil {
return map[string]MetaValue{"": {DataType: MDTString, Value: fmt.Sprintf("Failed to Marshal %T:\n%+v", value, value)}}
}
return map[string]MetaValue{"": {DataType: MDTString, Value: string(jsonval)}}
}
func addMetaPrefix(prefix string, m map[string]MetaValue) map[string]MetaValue {
if len(m) == 1 {
for k, v := range m {
if k == "" {
return map[string]MetaValue{prefix: v}
}
}
}
r := make(map[string]MetaValue, len(m))
for k, v := range m {
r[prefix+"."+k] = v
}
return r
}

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