using entgo orm i have a campaigns table that needs to have a one to many relation with another table called abtesting, note that these are arranged in different directories such that the schema for campaign is in /modules/campaigns/ent/schema/campaign.go and the schema for abtesting is in /modules/abtesting/ent/schema/abtesting.go
Ent supports cross-package relations. Here’s how to set up a one-to-many relation from Campaign
to ABTesting
, assuming each Campaign
can have multiple ABTesting
entries.
package schema
import (
"entgo.io/ent"
"entgo.io/ent/schema/edge"
)
type Campaign struct {
ent.Schema
}
func (Campaign) Fields() []ent.Field {
return nil
}
func (Campaign) Edges() []ent.Edge {
return []ent.Edge{
edge.To("abtests", Abtesting.Type), // refers to the schema name in /abtesting
}
}
package schema
import (
"entgo.io/ent"
"entgo.io/ent/schema/edge"
"/modules/campaigns/ent/schema" // import campaign schema for reference
)
type Abtesting struct {
ent.Schema
}
func (Abtesting) Fields() []ent.Field {
return nil
}
func (Abtesting) Edges() []ent.Edge {
return []ent.Edge{
edge.From("campaign", schema.Campaign.Type).
Ref("abtests").
Unique().
Required(),
}
}
<your-module>
with the correct Go import path for your repo.schemaCampaign "..."
).ent/generate.go
file includes both packages in the codegen config, like:package main
import (
"log"
"entgo.io/ent/cmd/ent/entc"
"entgo.io/ent/entc/gen"
)
func main() {
err := entc.Generate("./modules/campaigns/ent/schema",
&gen.Config{
Target: "./modules/campaigns/ent",
// configure multiple schema paths
SchemaPaths: []string{
"./modules/campaigns/ent/schema",
"./modules/abtesting/ent/schema",
},
},
)
if err != nil {
log.Fatalf("running ent codegen: %v", err)
}
}
Want help writing a test for this or a usage example with creation/querying?
won’t the Abtesting.Type be undefined?
Yes — Abtesting.Type will be undefined in the Campaign schema file unless you import it explicitly. Since the schemas are in different directories, you need to import the Abtesting schema package into the Campaign schema file.
package schema
import (
"entgo.io/ent"
"entgo.io/ent/schema/edge"
// Import the abtesting schema package
abtestingschema "/modules/abtesting/ent/schema"
)
type Campaign struct {
ent.Schema
}
func (Campaign) Fields() []ent.Field {
return nil
}
func (Campaign) Edges() []ent.Edge {
return []ent.Edge{
edge.To("abtests", abtestingschema.Abtesting.Type),
}
}
package schema
import (
"entgo.io/ent"
"entgo.io/ent/schema/edge"
campaignschema "/modules/campaigns/ent/schema"
)
type Abtesting struct {
ent.Schema
}
func (Abtesting) Fields() []ent.Field {
return nil
}
func (Abtesting) Edges() []ent.Edge {
return []ent.Edge{
edge.From("campaign", campaignschema.Campaign.Type).
Ref("abtests").
Unique().
Required(),
}
}
This creates a proper bidirectional relation:
Campaign.ABTests
(one-to-many)ABTesting.Campaign
(many-to-one)Let me know if you want a codegen setup that handles multiple modules cleanly.
actually all schemas are in schema package, although they are in different directories, using your way i am getting errors for circular and broken imports
Got it — if all schemas are in the same package schema
, you should not import any schema explicitly. Ent discovers types based on the same package name during codegen. So do not cross-import types manually — Ent resolves them internally using the names.
Even if your files are in different directories, as long as they use package schema, Ent will resolve Abtesting.Type
just fine.
package schema
import (
"entgo.io/ent"
"entgo.io/ent/schema/edge"
)
type Campaign struct {
ent.Schema
}
func (Campaign) Fields() []ent.Field {
return nil
}
func (Campaign) Edges() []ent.Edge {
return []ent.Edge{
edge.To("abtests", Abtesting.Type), // This is OK as long as Abtesting is in the same package
}
}
package schema
import (
"entgo.io/ent"
"entgo.io/ent/schema/edge"
)
type Abtesting struct {
ent.Schema
}
func (Abtesting) Fields() []ent.Field {
return nil
}
func (Abtesting) Edges() []ent.Edge {
return []ent.Edge{
edge.From("campaign", Campaign.Type).
Ref("abtests").
Unique().
Required(),
}
}
In your entc.Generate
, the SchemaPaths must include all schema files under the unified schema
package.
entc.Generate("./modules/ent/schema",
&gen.Config{
Target: "./modules/ent",
SchemaPaths: []string{
"./modules/campaigns/ent/schema",
"./modules/abtesting/ent/schema",
},
},
)
Or just consolidate all schemas under a virtual path like ./modules/ent/schema
and symlink or move files there.
package schema
consistent.Type
name within the same package.SchemaPaths
config includes all directories.Want help setting up a monorepo-style layout that avoids these issues long-term?
in /modules/campaigns/ent/schema/campaign.go
// Edges of the Campaign. func (Campaign) Edges() []ent.Edge { return []ent.Edge{ edge.To(“tags”, Tag.Type), edge.To(“templates”, Template.Type), edge.To(“edm_config”, EmailToDecisionMakerConfig.Type), edge.To(“updates”, Update.Type), edge.To(“comments”, PetitionComment.Type), edge.To(“abtests”, ABTesting.Type), —-> ABTesting undefined } }
and in /modules/abtesting/ent/schema/abtesting.go func (ABTesting) Edges() []ent.Edge { return []ent.Edge{ edge.From(“campaigns”, Campaign.Type).Ref(“abtests”), —- } }
Thanks — this makes the issue very clear.
You’re using split directories, but single package schema, and Ent expects all schema files to be discoverable under one logical package during codegen. Your setup is valid, but Ent won’t see ABTesting.Type
in campaign.go
unless the file is part of the same logical package during generation.
Ent does not support referencing types between schema files in different directories, even if they use the same package schema
. The Type
identifiers like ABTesting.Type
are generated and resolved by Ent at compile/codegen time — not via Go imports.
Put all your schema files in one place:
/modules/ent/schema/
├── campaign.go
├── abtesting.go
├── tag.go
└── ...
Then update your codegen:
entc.Generate("./modules/ent/schema",
&gen.Config{
Target: "./modules/ent",
},
)
✅ Now ABTesting.Type
is in scope everywhere.
Keep your modular directory structure, but symlink files into a central schema directory:
mkdir -p modules/ent/schema
ln -s ../../../campaigns/ent/schema/campaign.go modules/ent/schema/campaign.go
ln -s ../../../abtesting/ent/schema/abtesting.go modules/ent/schema/abtesting.go
# ... other schemas
Make sure all schema files are visible to Ent in that single folder.
Let me know if you want a shell script to automate symlinking your modular schema structure into a single folder before codegen.
tricky, because Abtesting a separate table in db, i need to have it in different dir, symlinks make me uneasy, any other way?
You’re right to be skeptical — symlinks are brittle and awkward, especially in cross-platform or CI environments. Let’s go with a cleaner, more robust alternative:
Keep all schema definitions in one physical directory, e.g.:
/modules/ent/schema/
├── campaign.go
├── abtesting.go
├── ...
This is required so Ent can resolve types like ABTesting.Type
.
Then, structure the rest of your domain logic however you like:
/modules/campaigns/
└── service.go
/modules/abtesting/
└── service.go
Schema = centralized Logic = modular
This preserves:
Ent uses AST analysis and doesn’t compile or import your code like Go. So:
ABTesting.Type
.This is a current architectural constraint of Ent — by design.
If you want to preserve your modular directory structure for clarity, you can:
Just organize by prefix or comment:
/modules/ent/schema/
├── campaign_schema.go // for Campaign
├── abtesting_schema.go // for ABTesting
├── ...
Or:
// campaign_schema.go
// +build ent
package schema
// -- campaign-related schemas here
// abtesting_schema.go
// +build ent
package schema
// -- abtesting-related schemas here
It’s not physical module boundaries, but it preserves separation by naming and editor tooling.
There’s no safe way to split Ent schemas into different directories without breaking type resolution. Centralize schema files — it’s the only stable path.
Let me know if you want help refactoring toward this structure without breaking things.
went with the Recommended Approach
Perfect call. That setup buys you stability, clarity, and no Ent codegen headaches. Modular logic, centralized schema — it’s the cleanest line through Ent’s constraints.
If you want a helper script to validate schema health or enforce the structure during CI, just say the word. Otherwise: you’re on solid footing.
what does “import cycle not allowed” mean
“import cycle not allowed” means:
Two or more Go packages are importing each other directly or indirectly, which Go does not allow.
// In package A
import "myapp/b"
// In package B
import "myapp/a"
This forms a loop:
a → b → a
Go builds packages top-down, and cycles break that model.
When using schema files across directories:
campaign.go
imports abtesting.go
for ABTesting.Type
abtesting.go
also imports campaign.go
for Campaign.Type
→ 💥 Boom. You’ve got a cycle.If you’ve just centralized your schema, and still hit this error — there’s likely a lingering cross-package import in a service, model, or helper file. Want help tracking that down?
use the tree command to list dirs only
To list directories only using the tree
command:
tree -d
$ tree -d
.
├── modules
│ ├── abtesting
│ ├── campaigns
│ └── ent
│ └── schema
└── scripts
-L 2
→ limit depth to 2 levels-I 'node_modules|vendor'
→ ignore directoriestree -d -L 2 -I 'vendor|node_modules|dist'
Want the output redirected or processed (e.g. JSON or grep’d)?
here is how my project is structred
/modules/ /modules/campaigns/ent/schema
i want to use these campaign schemas in a controller/service in another part of the app, say /modules/tracking
is it feasible, if so how?
Yes — totally feasible, and a clean architecture supports it. Here’s how to pull it off safely and modularly.
Use Ent-generated Campaign
schema/models (from /modules/campaigns/ent/schema
) inside another module, e.g. /modules/tracking
.
Your campaign schema is in:
/modules/campaigns/ent/schema
Ent codegen target is something like:
/modules/campaigns/ent
Which generates:
Client
Campaign
modelCampaignQuery
, etc.In your tracking service/controller:
import (
campaignent "your_project/modules/campaigns/ent"
)
You now have access to:
campaignent.Campaign
campaignent.Client
campaignent.CampaignQuery
, etc.If your app passes around a shared Ent Client
, you can do:
func (s *TrackingService) TrackCampaignStuff(ctx context.Context) error {
// Assuming s.client is *ent.Client
campaigns, err := s.client.Campaign.
Query().
Where(campaignent.CampaignFieldNameEQ("value")).
All(ctx)
// ...
}
If you only want to expose part of the client, you can wrap the campaign client:
type CampaignRepo interface {
GetByID(ctx context.Context, id int) (*campaignent.Campaign, error)
}
[ /modules/ent ] (codegen output)
↑
[ /modules/campaigns/ent/schema ] (schema source)
↓
[ /modules/tracking ] (consumes campaign client/models)
So:
schema
packages — only import generated Ent code.Want a real example or wrapper interface to abstract CampaignClient
for injection into /tracking
?
this import works campaigns “because/internal/modules/campaigns/ent” but usage abtestingSvc := abtesting.NewABTestService(*campaigns.Client, logger.Log)
says *campaigns.Client (type) is not an expression
Ah — very close. That error means you’re trying to use *campaigns.Client
as if it were a variable, but it’s actually a type, not a value.
abtestingSvc := abtesting.NewABTestService(*campaigns.Client, logger.Log)
// ↑ This is a type, not a value!
You’re dereferencing a type, which is invalid. That’s like saying:
var x = *int // ← nonsensical
You need to pass an actual instance of the *ent.Client
, not the type. For example:
import (
campaigns "because/internal/modules/campaigns/ent"
)
// Somewhere you’ve created the client already:
client := campaigns.NewClient(...) // or injected
// Then you pass it:
abtestingSvc := abtesting.NewABTestService(client, logger.Log)
Or if the client was already available:
abtestingSvc := abtesting.NewABTestService(existingClient, logger.Log)
Wrong | Why |
---|---|
*campaigns.Client | Dereferencing a type — not valid |
campaigns.Client{} | Constructs empty client — likely broken |
✅ campaigns.NewClient(…) | Proper instantiation |
✅ existingEntClient | Passing actual value |
Want help wiring up Ent clients properly per module or setting up a shared NewEntClient()
factory?
these fields are optional in the schema field.Time(“start_at”).Optional(), field.Time(“end_at”).Optional(),
optional in the request body Start time.Time json:”start_at,omitempty” End time.Time json:”end_at,omitempty”
so what if i call their entgo set functions during creation and the request does not have them? abtest, err := abs.client.ABTest.Create(). SetName(request.Name). SetDescription(request.Description). SetSuccessMetric(request.SuccessMetric). SetStartAt(request.Start). SetEndAt(request.End). SetCampaignID(request.CampaignId). AddCampaignIDs(request.CampaignId). Save(ctx)
Great question — and this dives right into a subtle Go + Ent behavior pattern.
When you declare:
field.Time("start_at").Optional()
Ent allows you to skip setting it entirely. But in your request struct:
Start time.Time `json:"start_at,omitempty"`
If the field is not in the JSON, it still gets the zero time.Time{} value, which is valid, but may not be meaningful.
SetStartAt(request.Start) // always called, even if Start was omitted
Which effectively does:
SetStartAt(time.Time{}) // <- zero value, NOT nil
Ent will store 0001-01-01 00:00:00 in DB, which you probably don’t want.
Make the time fields pointers, so you can distinguish between:
nil
)&t
)type ABTestRequest struct {
Start *time.Time `json:"start_at,omitempty"`
End *time.Time `json:"end_at,omitempty"`
}
Then in your service:
builder := abs.client.ABTest.Create().
SetName(request.Name).
SetDescription(request.Description).
SetSuccessMetric(request.SuccessMetric).
SetCampaignID(request.CampaignId).
AddCampaignIDs(request.CampaignId)
if request.Start != nil {
builder = builder.SetStartAt(*request.Start)
}
if request.End != nil {
builder = builder.SetEndAt(*request.End)
}
abtest, err := builder.Save(ctx)
Field | JSON | DB |
---|---|---|
time.Time | missing → time.Time{} | Stored as 0001-01-01 |
*time.Time | missing → nil | Not set at all |
Use *time.Time
for all optional field.Time(...).Optional()
fields in Ent.
Let me know if you want a helper function to cleanly unwrap *time.Time
or handle default fallbacks.
lets fix the types on this func func (abs abTestingService) GetCampaignABTest(ctx context.Context, id uuid.UUID) ([]models.ABTestModel, error) { abtests, err := abs.client.ABTest.Query().Where(abtest.CampaignID(id)).All(ctx) if err != nil { return nil, err }
abtest is of type []*“because/internal/modules/campaigns/ent”.ABTest) where ABTest schema is // Fields of the ABTest. func (ABTest) Fields() []ent.Field { return []ent.Field{ field.UUID(“id”, uuid.UUID{}). Default(uuid.New). Immutable(), field.String(“name”), field.String(“description”).Optional(), field.String(“status”).Default(“draft”), //”draft”,”active”,”paused”,”completed” field.UUID(“stageId”, uuid.UUID{}).Unique(), field.UUID(“campaign_id”, uuid.UUID{}), field.Time(“start_at”).Optional(), field.Time(“end_at”).Optional(), field.String(“success_metric”), // sign, share, donate etc field.Time(“created_at”).Default(time.Now), field.Time(“updated_at”).Default(time.Now).UpdateDefault(time.Now), } }
i want the return value to match this example { “tests”: [ { “id”: “550e8400-e29b-41d4-a716-446655440001”, “name”: “Homepage CTA Test”, “status”: “active”, “start_date”: “2025-03-15T00:00:00Z”, “end_date”: “2025-04-15T00:00:00Z”, “success_metric”: “sign” } ], “count”: 1 }
Got it. You want GetCampaignABTest
to return a response like:
{
"tests": [ { ... } ],
"count": 1
}
Let’s fix the function’s return type and map the Ent models properly.
In models/ab_test.go
(or similar):
type ABTestModel struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
Status string `json:"status"`
StartDate *time.Time `json:"start_date,omitempty"`
EndDate *time.Time `json:"end_date,omitempty"`
SuccessMetric string `json:"success_metric"`
}
type ABTestListResponse struct {
Tests []*ABTestModel `json:"tests"`
Count int `json:"count"`
}
func (abs *abTestingService) GetCampaignABTest(ctx context.Context, id uuid.UUID) (*models.ABTestListResponse, error) {
records, err := abs.client.ABTest.
Query().
Where(abtest.CampaignID(id)).
All(ctx)
if err != nil {
return nil, err
}
tests := make([]*models.ABTestModel, 0, len(records))
for _, r := range records {
test := &models.ABTestModel{
ID: r.ID,
Name: r.Name,
Status: r.Status,
StartDate: toPtrIfValid(r.StartAt),
EndDate: toPtrIfValid(r.EndAt),
SuccessMetric: r.SuccessMetric,
}
tests = append(tests, test)
}
return &models.ABTestListResponse{
Tests: tests,
Count: len(tests),
}, nil
}
If you want to guard against 0001-01-01
being treated as valid:
func toPtrIfValid(t time.Time) *time.Time {
if t.IsZero() {
return nil
}
return &t
}
You’ll now get:
{
"tests": [
{
"id": "550e8400-e29b-41d4-a716-446655440001",
"name": "Homepage CTA Test",
"status": "active",
"start_date": "2025-03-15T00:00:00Z",
"end_date": "2025-04-15T00:00:00Z",
"success_metric": "sign"
}
],
"count": 1
}
Let me know if you want to add sorting or filter by status/date in the same query.
moving on, say a schema has an edge to abtest like so
// Edges of the Campaign. func (Campaign) Edges() []ent.Edge { return []ent.Edge{ edge.To(“tags”, Tag.Type), edge.To(“templates”, Template.Type), edge.To(“edm_config”, EmailToDecisionMakerConfig.Type), edge.To(“updates”, Update.Type), edge.To(“comments”, PetitionComment.Type), edge.To(“abtests”, ABTest.Type), } }
then while getting one of the rows campaign_obj, err := svc.client.Campaign.Get(ctx, cid)
how can i include the abtests info?
Use .Query().With<Edge>()
— that’s how Ent eagerly loads related edges.
campaignObj, err := svc.client.Campaign.
Query().
Where(campaign.IDEQ(cid)).
WithAbtests().
Only(ctx)
This loads the campaign with the related ABTests pre-fetched.
abtests := campaignObj.Edges.Abtests
That’s a slice of *ent.ABTest
.
Ent does this in one query using a SQL JOIN unless the relationship is large — in that case it may use batching.
No .Preload()
needed like GORM — it’s .With<edge>()
for each edge.
svc.client.Campaign.
Query().
Where(campaign.IDEQ(cid)).
WithAbtests().
WithTags().
Only(ctx)
Stack up .WithXYZ()
to hydrate all needed edges at once.
Let me know if you want to filter or sort the abtests while joining.
abtests, err := campaign_obj.QueryAbtests().Where(abtest.CampaignIDEQ(cid)) add the condition abtest.Status(“active”) to this
You got it — just chain the Where
conditions:
abtests, err := campaignObj.
QueryAbtests().
Where(
abtest.CampaignIDEQ(cid),
abtest.StatusEQ("active"),
).
All(ctx)
.Where(...)
CampaignIDEQ(cid)
if campaignObj
is already the parent — the query is already scoped by that edge.So, simpler & correct version:
abtests, err := campaignObj.
QueryAbtests().
Where(abtest.StatusEQ("active")).
All(ctx)
Use this one unless you’re manually querying across campaigns.
uuid.UUID to string activeAbtest.ID,
Just use .String()
:
activeAbtest.ID.String()
That gives you the canonical UUID string like:
"550e8400-e29b-41d4-a716-446655440001"
No need for fmt.Sprintf
or conversion — clean and direct.
in this gin service functiuon there may be two query params, extract these
func (abs abTestingService) AssignToVariant(ctx context.Context, id uuid.UUID) (models.ABTestVariantModel, error) { panic(“impl”) } Query Parameters: session_id (optional) user_id (optional)
Extract them using ctx.Query(...)
, which returns an empty string if the param is missing.
Here’s the corrected function stub:
func (abs *abTestingService) AssignToVariant(ctx *gin.Context, id uuid.UUID) (*models.ABTestVariantModel, error) {
sessionID := ctx.Query("session_id")
userID := ctx.Query("user_id")
// Example debug
// fmt.Println("session:", sessionID, "user:", userID)
panic("impl")
}
⚠️ Note: You had ctx context.Context
, but for query params, you need ctx *gin.Context
. Adjust your handler signature accordingly. Want me to show how to wire this into a route handler too?
how about like this idStr := req.HttpRequest.URL.Query().Get(“id”)
Yes, that works — if you’re using raw *http.Request
. For example:
idStr := req.HttpRequest.URL.Query().Get("id")
✅ This is valid in lower-level net/http handlers or if you’re working with a custom request wrapper.
In Gin, you’re usually inside a handler with ctx *gin.Context
, and you should prefer:
idStr := ctx.Query("id")
It’s shorter, idiomatic, and handles form/query precedence correctly.
Context Type | Preferred Method |
---|---|
*gin.Context | ctx.Query(“param”) |
*http.Request | req.URL.Query().Get(“param”) |
So yes, your way works — just depends on what framework abstraction you’re working under. If you’re deep in a service layer with a custom wrapper like req.HttpRequest
, your version is valid. Want me to review that struct signature to confirm?
GET /tracking/v1/abtest/assign/:id this is the request endpoint, so the user_id and session_id are query params and id is path param i need all
Perfect — you’re mixing one path param and two query params. Here’s how to extract all three cleanly in a Gin handler:
func (abs *abTestingService) AssignToVariant(ctx *gin.Context) {
// Path param
idStr := ctx.Param("id")
id, err := uuid.Parse(idStr)
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid UUID"})
return
}
// Query params (optional)
sessionID := ctx.Query("session_id")
userID := ctx.Query("user_id")
// You can now use id, sessionID, userID
// For example:
result, err := abs.assignLogic(ctx, id, sessionID, userID)
if err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
ctx.JSON(http.StatusOK, result)
}
router.GET("/tracking/v1/abtest/assign/:id", abtestService.AssignToVariant)
GET /tracking/v1/abtest/assign/550e8400-e29b-41d4-a716-446655440001?session_id=sess123&user_id=user456
Let me know if you want fallback logic (e.g. use session_id if user_id is absent
) or parameter validation rules.
i’m in the controller, there is no gin context but req.HttpRequest.URL. is available
Got it — you’re in a controller layer where the raw *http.Request
is exposed via req.HttpRequest
. That means you’re dealing with the standard library, not Gin.
Here’s how to extract everything from req.HttpRequest
:
If you already parsed the path param (e.g. passed it down as id uuid.UUID
), no action needed.
But if you only have the full URL (e.g. /assign/550e8400-...
), you must extract it yourself. For example:
// Assuming your router doesn't do param parsing
parts := strings.Split(req.HttpRequest.URL.Path, "/")
idStr := parts[len(parts)-1] // crude but effective
id, err := uuid.Parse(idStr)
if err != nil {
return nil, fmt.Errorf("invalid UUID: %w", err)
}
Better yet: pass the parsed id
from the router if possible.
q := req.HttpRequest.URL.Query()
sessionID := q.Get("session_id")
userID := q.Get("user_id")
func (abs *abTestingService) AssignToVariant(req MyRequestWrapper, id uuid.UUID) (*models.ABTestVariantModel, error) {
q := req.HttpRequest.URL.Query()
sessionID := q.Get("session_id")
userID := q.Get("user_id")
// proceed with id, sessionID, userID
return abs.doAssignment(req.Context, id, sessionID, userID)
}
Let me know if you want a lightweight helper to wrap up query parsing and validation.
will this work for the id path param? idStr := req.HttpRequest.URL.Query().Get(“id”)
❌ No, that won’t work for the id
path parameter.
idStr := req.HttpRequest.URL.Query().Get("id")
This line only fetches query parameters, i.e., things like:
/assign?id=550e8400-e29b-41d4-a716-446655440001
But in your case, the id
is part of the path:
/assign/550e8400-e29b-41d4-a716-446655440001
So Query().Get("id")
returns an empty string.
You need to extract the ID from the URL path, for example:
// crude but works if ID is always the last segment
parts := strings.Split(req.HttpRequest.URL.Path, "/")
idStr := parts[len(parts)-1]
Then:
id, err := uuid.Parse(idStr)
if err != nil {
return nil, fmt.Errorf("invalid UUID: %w", err)
}
✅ Cleanest approach: pass the id
from the router into the controller explicitly if you can. If you’re already doing that, just skip the extraction entirely.
it is working elsewhere in my app for routes defined as trackingGroup.GET(“/abtest/assign/:id”, this is gonic/gin
Ah — now we’re aligned.
You’re using Gin, and trackingGroup.GET("/abtest/assign/:id", ...)
means Gin is already extracting the :id
path parameter for you.
So if you’re seeing this work elsewhere:
idStr := req.HttpRequest.URL.Query().Get("id")
That only works if somewhere upstream (like middleware or controller adapter) you’re injecting the path param as a query param into the req.HttpRequest.URL
. That’s not standard Gin behavior, so it sounds like your app has a custom abstraction (e.g., wrapping *gin.Context
and pushing :id
into query params).
Try logging the full query:
fmt.Println(req.HttpRequest.URL.RawQuery)
If id=...
is in there even though it’s part of the path, then yes — your app’s plumbing is injecting it. It’s unusual but valid if intentional.
Use:
idStr := ctx.Param("id")
If you’re abstracting away from *gin.Context
, then your req.HttpRequest.URL.Query().Get("id")
working implies you’re relying on that custom setup — just be aware it’s non-standard and might bite later in testing or porting.
Want to refactor away from that or keep riding it?
variant, err := abs.client.ABTestVariant.Query(). Where(abtestvariant.ID(id)). First(ctx)
if err != nil {
return nil, err
}
abtest, err := abs.client.ABTest.Query().
Where(abtest.IDEQ(variant.ID)).
First(ctx)
if err != nil {
return nil, err
}
campaign, err := abs.client.Campaign.Query().Where().First(ctx)
if err != nil {
return nil, err
}
i want to get the associated campaign where campaigns has an edge to abtest
You’re almost there — but the second query is wrong: you’re querying for an ABTest where abtest.ID == variant.ID
, which isn’t the relationship you want.
ABTestVariant
→ belongs to ABTest
ABTest
→ belongs to Campaign
So, to fetch the associated Campaign
, you can walk the edges:
// 1. Get variant
variant, err := abs.client.ABTestVariant.Query().
Where(abtestvariant.ID(id)).
First(ctx)
if err != nil {
return nil, err
}
// 2. Get the ABTest this variant belongs to
abtest, err := variant.QueryABTest().Only(ctx)
if err != nil {
return nil, err
}
// 3. Get the Campaign this ABTest belongs to
campaign, err := abtest.QueryCampaign().Only(ctx)
if err != nil {
return nil, err
}
abtestvariant
to have an edge To("abtest", ABTest.Type).Required().Unique()
abtest
to have an edge To("campaign", Campaign.Type).Required().Unique()
or a UUID field and inverse edgeLet me know if those edges aren’t set up — I can show you how to add them.
get all stages for this campaign where the field in campaign is field.JSON(“stages”, []*models.CampaignJourneyStage{}).Optional(),
To query all stages from a campaign when stages
is a JSON field like:
field.JSON("stages", []*models.CampaignJourneyStage{}).Optional()
you can’t “query” inside the JSON field using Ent directly — instead, you retrieve the campaign, then access and unmarshal the JSON field in Go.
campaign, err := abs.client.Campaign.Get(ctx, campaignID)
if err != nil {
return nil, err
}
var stages []*models.CampaignJourneyStage
err = json.Unmarshal(campaign.Stages, &stages)
if err != nil {
return nil, fmt.Errorf("failed to parse stages: %w", err)
}
return stages, nil
Assuming campaign.Stages
is a []byte
or compatible JSON-mapped Go type (depending on how Ent codegen inferred the type).
return campaign.Stages, nil
Want to confirm the field type Ent generated for .JSON(...)
? I can help with that too.
cannot use campaign.Stages (variable of type []*“because/internal/modules/campaigns/models”.CampaignJourneyStage) as []byte value in argument to json.Unmarshal compiler (IncompatibleAssign)
Perfect — that confirms what Ent generated for you: the field is already a Go value of type
[]*models.CampaignJourneyStage
So, no need to json.Unmarshal at all. Just return it directly.
campaign, err := abs.client.Campaign.Get(ctx, campaignID)
if err != nil {
return nil, err
}
return campaign.Stages, nil
Done.
Let me know if you need to filter or transform the stages before returning.
i had this in mind
// 3. Get the Campaign this ABTest belongs to
campaign, err := abtest.QueryCampaigns().Only(ctx)
if err != nil {
return nil, err
}
// 4. Get Campaign Stages
var stages []*campaignModels.CampaignJourneyStage
err = json.Unmarshal(campaign.Stages, &stages)
if err != nil {
return nil, fmt.Errorf("failed to parse stages: %w", err)
} # ChatGPT Ah, I see — your thinking was correct in terms of **intent**, but unnecessary in execution.
Since your campaign.Stages
is already of type
[]*campaignModels.CampaignJourneyStage
there’s no need for JSON decoding — Ent did that for you when it fetched the record.
err = json.Unmarshal(campaign.Stages, &stages)
You’re trying to unmarshal a []*CampaignJourneyStage
into another []*CampaignJourneyStage
, which makes no sense — it’s already fully unmarshalled.
// 4. Get Campaign Stages
stages := campaign.Stages
or if you need to return them:
return campaign.Stages, nil
Let me know if you want to filter or enrich the stages (e.g. sort by timestamp, only return completed ones, etc).
// 4. Get Campaign Stages var stages []*campaignModels.CampaignJourneyStage stages = campaign.Stages
return &models.ABTestVariantModel{
Config: stages,
}, nil
cannot use stages (variable of type []*“because/internal/modules/campaigns/models”.CampaignJourneyStage) as map[string]any value in struct literal
That error tells us Config
is of type map[string]any
, but you’re assigning a []*CampaignJourneyStage
. These types are not compatible.
If Config
is meant to store campaign stages, and they’re always structured as []*CampaignJourneyStage
, update your model:
type ABTestVariantModel struct {
Config []*CampaignJourneyStage `json:"config"`
}
This is the cleanest option if the structure is known and consistent.
If Config
must stay as map[string]any
(e.g., for flexibility or dynamic JSON blobs):
return &models.ABTestVariantModel{
Config: map[string]any{
"stages": stages,
},
}, nil
This wraps your structured data under a key in the Config
map.
Config
is a known structure → use a typed fieldConfig
is meant to be flexible/dynamic → wrap your structured data into itLet me know which path you prefer — I can help clean up the full struct definition if needed.