georgysavva / scany

Library for scanning data from a database into Go structs and more
MIT License
1.23k stars 67 forks source link

Nested struct is not nil even though the object is empty #96

Closed Marahin closed 1 year ago

Marahin commented 1 year ago

Hi.

I'm having a Player struct that has different related tables mapped to structs and nested within (Player may have multiple Deaths, and an optional Guild). I have noticed that the Guild relation is returning a pointer to an empty struct (*Guild{}) instead of a nil pointer. I believe that because of that, when marshaling it into JSON, the Guild still remains in the response, as an empty object {}.

I would expect that it is completely omitted.

Definition of types:

type Player struct {
    ID                          int        `json:"id"`
    Name                        string     `json:"name"`
    FormerNames                 *string    `json:"former_names"`
    FormerWorld                 *string    `json:"-"`
    GuildPlayers                *int       `json:"-"`
    GuildID                     *int       `json:"-"`
    Sex                         *string    `json:"sex"`
    Vocation                    *string    `json:"vocation"`
    Level                       int        `json:"level"`
    World                       *string    `json:"world"`
    CreatedAt                   time.Time  `json:"created_at"`
    UpdatedAt                   time.Time  `json:"updated_at"`
    CharacterExtractorUpdatedAt *time.Time `json:"character_extractor_updated_at"`

    Guild              *Guild               `json:"guild,omitempty"`
    Deaths             []*Death             `json:"deaths,omitempty"`
    ExperienceRecords  []*ExperienceRecord  `json:"experience_records,omitempty"`
    PlayerDailyProfits []*PlayerDailyProfit `json:"player_daily_profits,omitempty"`
    Transfers          []*Transfer          `json:"transfers,omitempty"`
}

type Guild struct {
    ID        *int       `json:"id,omitempty"`
    Name      *string    `json:"name,omitempty"`
    World     *string    `json:"world,omitempty"`
    CreatedAt *time.Time `json:"created_at,omitempty"`
    UpdatedAt *time.Time `json:"updated_at,omitempty"`
}

type Death struct {
    ID           int       `json:"id"`
    PlayerID     int       `json:"player_id"`
    DeathMessage string    `json:"death_message"`
    DiedAt       time.Time `json:"died_at"`
    CreatedAt    time.Time `json:"created_at"`
    UpdatedAt    time.Time `json:"updated_at"`

    PlayerName string `json:"name,omitempty"`
    Level      int    `json:"level,omitempty"`
}

type ExperienceRecord struct {
    ID        int       `json:"-"`
    PlayerID  int       `json:"-"`
    CreatedAt time.Time `json:"created_at"`
    UpdatedAt time.Time `json:"updated_at"`
    Absolute  int       `json:"absolute"`
    Relative  int       `json:"relative"`
}

type PlayerDailyProfit struct {
    ID        int       `json:"-"`
    PlayerID  int       `json:"-"`
    CreatedAt time.Time `json:"created_at"`
    UpdatedAt time.Time `json:"updated_at"`
    Profit    int       `json:"profit"`
}

type Transfer struct {
    ID        int       `json:"id"`
    PlayerID  int       `json:"player_id"`
    From      string    `json:"from"`
    To        string    `json:"to"`
    CreatedAt time.Time `json:"created_at"`
}

The SQL query:

select
    p.*,
    guild.id as "guild.id",
    guild.name as "guild.name",
    guild.world as "guild.world"
from
    players p
left join guilds guild on
    p.guild_players = guild.id
where
    p.name = $1

The result is something along these lines:

id name former_names sex vocation level world former_world created_at updated_at guild_players character_extractor_updated_at guild.id guild.name guild.world
425637 Thisisaname male Elite Knight 304 WorldName 2021-07-06 14:59:48.421 +0200 2022-09-30 20:08:48.100 +0200 2022-09-30 20:08:48.100 +0200 [NULL] [NULL] [NULL]

How is this all called?


func (q *Query) QueryRow(ctx context.Context, db *pgxpool.Pool, target any) error {
    return pgxscan.Get(ctx, db, target, q.SQL, q.Parameters...)
}
Marahin commented 1 year ago

obraz

Before passing the object to pgxscan.Get

Marahin commented 1 year ago

obraz After being processed by pgxscan. Notice how Guild is no longer nil, but now instead is a *Guild, however all its attributes are nil pointers.

georgysavva commented 1 year ago

Hi @Marahin! Thank you for opening this issue.

The behavior you observe is expected. Scany initializes all nested structs to be able to save results from the database, even NULLs. I would suggest thinking about nested structs, not as objects (because scany isn't an ORM), but rather as a subset of columns you want to reuse between the queries.

Another popular database scanning library sqlx does it the same way as scany.

If you need your structs to act as objects — with proper M2M and O2M relations and nils— you can always transform structs you use with scany to those objects in your application code.

I hope that helps. Let me know if you have any questions.

Marahin commented 1 year ago

Thank you for time spent responding to this issue @georgysavva. Indeed, my expectations were exactly how you described them, but I am happy to hear that this behavior is expected. I understand the rationale behind it, it makes sense.