Yes, there is a middle ground. Elixir's Ecto does this well.
Database rows map to structs. But it doesn't try to figure out how to mutate the data for you to keep the struct in sync with the database. All mutations are explicit using changesets (which can also be used for other non-database purposes, like validating user input for an API.)
There is no implicit preloading of data. You have to explicitly preload.
Data is never fetched implicitly. You have to call Repo.all or Repo.one or something.
It has a query DSL that's a thin wrapper over SQL. It's well-designed and I've never had a problem with it.