ROM 3.0 Released
We're happy to announce the release of rom 3.0.0, a big release which comes with the first stable versions of rom-sql 1.0.0 and rom-repository 1.0.0. Changes and improvements in rom core gem focused mostly on extending functionality of relation Schema API and Command API, as well as removing all deprecated core APIs. The biggest highlight are new features in rom-sql and rom-repository.
Extended Schemas
Starting from rom 3.0.0 all relations, regardless of the adapter, have schemas. It means that Schema is now a 1st class API available in all adapters.
Schemas now use adapter-specific attribute types, which allowed us to implement all kinds of new features in rom-sql that make building complex queries much simpler. We also improved schema inference and added support for more PostgreSQL types like enum
, point
or inet
. Furthermore, it's now possible to use inference along with explicit attribute definitions, which is useful in cases where inferrer doesn't support some custom column type, or when you simply want to customize your schema.
Advanced projections
Relation schemas are always available, they keep track of the current attributes that relation tuples will include. This is a huge improvement, since previously schemas were only the representation of canonical relations (defined by your actual database schema). Projections go through schemas, and they adjust their attributes automatically. This gives us complete information about data that any relation can return.
In rom-sql schema attributes are extended with SQL-specific features, which allows queries like this:
class Users < ROM::Relation[:sql]
schema(infer: true)
def duplicated_emails
select { [email, int::count(id).as(:count)] }.
group(:email).
order(:email)
end
# SELECT "email", COUNT("id") AS "count"
# FROM "users"
# GROUP BY "email"
# ORDER BY "email"
end
You can use both blocks or refer to attributes directly through Relation#[]
method which returns schema attributes identified by their canonical names:
class Users < ROM::Relation[:sql]
schema(infer: true)
def duplicated_emails
select(self[:email], self[:id].func { int::count(id).as(:count) }).
group(:email).
order(:email)
end
# SELECT "email", COUNT("id") AS "count"
# FROM "users"
# GROUP BY "email"
# ORDER BY "email"
end
Resulting relation views include complete information about their current schema:
users.duplicated_emails.schema.attributes
# [
# #<ROM::SQL::Attribute[NilClass | String] name=:email source=ROM::Relation::Name(users)>,
# #<ROM::SQL::Function[Integer] func=#<Sequel::SQL::Function @name=>"COUNT", @args=>[#<ROM::SQL::Attribute[Integer] primary_key=true name=:id source=ROM::Relation::Name(users)>], @opts=>{}> alias=:count>
# ]
This plays a major role in automatic mapping in repositories, as they can define structs with all attribute type information provided by relations.
Support for SQL functions
The count
function we used in the previous example probably caught your attention—this is a new feature which allows you to use any SQL function with arbitrary arguments, including relation attributes, or anything that can be dumped into a valid SQL. The syntax is always:
type_annotation::function_name(*arguments)
It returns a ROM::SQL::Function
object which works like any other schema attribute, and you can qualify it or provide an alias, or use a boolean expression with various operators. Functions work with select
, order
and where
. The only difference is that select
requires a type annotation, and the other methods don't.
Here's another example:
class Users < ROM::Relation[:sql]
schema(infer: true) do
associations do
has_many :tasks
end
end
def busy_people
select(:id, :name, tasks[:id].func { int::count(id).as(:task_count) }).
left_join(tasks).
group(:id).
having { count(id.qualified) > 1 }
end
# SELECT "users"."id", "users"."name", COUNT("tasks"."id") AS "task_count"
# FROM "users"
# LEFT JOIN "tasks" ON ("users"."id" = "tasks"."user_id")
# GROUP BY "users"."id"
# HAVING (count("users"."id") > 1) ORDER BY "users"."id"
end
Maybe you noticed that we passed tasks
relation object to left_join
—this is another new feature.
Improved joins
You can now pass relation objects to join
, left_join
and right_join
, and, assuming you configured associations, your relation will do the work for you to join the correct table with join conditions already set. Furthermore, it will be automatically qualified. Here's what it means:
class Tasks < ROM::Relation[:sql]
schema(infer: true) do
associations do
belongs_to :user
end
end
def with_user
# "join(:users, user_id: :id).qualified" becomes:
join(users)
end
end
There's also a new method to simplify finding all associated tuples through associations, called Relation#assoc
, it uses configured associations to prepare a joined relation for you. Let's say we want to find all user priority tasks:
class Users < ROM::Relation[:sql]
schema(infer: true) do
associations do
has_many :tasks
end
end
def priority_tasks(user_ids)
assoc(:tasks).
select(:id, :title, self[:name].qualified.as(:user))
where(priority: 1, user_id: user_ids)
end
end
users.priority_tasks([1, 2, 3])
# SELECT "tasks"."id", "tasks"."user_id", "tasks"."title", "users"."name" AS "user"
# FROM "tasks"
# INNER JOIN "users" ON ("users"."id" = "tasks"."user_id")
# WHERE ("priority" = 1) AND ("user_id" IN(1, 2, 3))
# ORDER BY "tasks"."id"
Custom association views
Another interesting feature is the ability to extend default association relations with a custom view. This is useful in cases where you would like to add more attributes to the resulting relation, change order etc.
Let's say we have users with accounts, and would like to include position
from the join table and order accounts by that column:
class Users < ROM::Relation[:sql]
schema(infer: true) do
associations do
has_many :accounts, through: :users_accounts, view: :ordered
end
end
end
class Accounts < ROM::Relation[:sql]
schema(infer: true)
view(:ordered) do
schema do
append(relations[:users_accounts][:position])
end
relation do
order(:position)
end
end
end
We use View DSL in this case, as it provides schema information up-front, before a relation is even initialized. It's one of the core features in rom that allows defining composable relations. Associations are based on this feature, but you can use it without associations too.
Bi-directional coercions
In the first version of Schema API, canonical attribute types were used by commands exclusively. Starting with rom 3.0.0 you can also define read
attributes, which will be used by relations when they read their tuples.
Let's say we have address JSONB column, and we want a custom address object back that uses JSONB attributes:
Address = Struct.new(:country, :city, :street, :zipcode)
class Users < ROM::Relation[:sql]
AddressType = Types.Constructor(Address) { |value| Address.new(*value) }
schema(infer: true) do
attribute :address, Types::PG::JSONB, read: AddressType
end
end
users.select(:address).to_a
# [{:address=>#<struct Address country="Poland", city="Krakow", street="Street 1"}]
For now this is very explicit, in the near future we'll add various convention-based improvements, so that specifying read
types is more concise.
Improved repositories
We added support for transactions, custom commands in changesets, run-time changeset mapping with custom blocks, associating data via changesets, asking for custom objects when committing a changeset... check out updated docs to learn more, and here are some of the highlights.
Comitting changesets
Changesets are now standalone objects, with a new Changeset#commit
method which allows you to store them conveniently in your database.
Here's a simple example of a changeset which saves a new user:
# assuming we have a repo like that:
class UserRepo < ROM::Repository[:users]
end
user_repo.changeset(name: "Jane", email: "jane@doe.org").commit
# => {:id=>1, :name=>"Jane", :email=>"jane@doe.org"}
Committing changesets via repositories
Changesets simply return whatever your database returned, but you can commit them via repositories that will convert raw data to rom structs:
# assuming we have a repo like that:
class UserRepo < ROM::Repository[:users]
command :create
end
new_user = user_repo.changeset(name: "Jane", email: "jane@doe.org")
user_repo.create(new_user)
# => #<ROM::Struct[User] id=1 name="Jane" email="jane@doe.org">
Powerful data transformations
Changeset now support custom data transformations, with many builtin functions provided by transproc gem. You can define your custom changeset classes and specify how data must be transformed before we can pass it to the underlying database command:
class NewUser < ROM::Changeset::Create[:users]
map do
unwrap :address, prefix: true
end
end
new_user = user_repo.changeset(NewUser).data(
name: "Jane",
address: {
city: "Krakow", country: "Poland", street: "Street 1", zipcode: "1234"
}
)
new_user.commit
# => {:id=>1, :name=>"Jane", address_city: "Krakow", address_country: "Poland", address_street: "Street 1", address_zipcode: "1234"}
You can also pass an argument to .map
and in that case you can use arbitrary code to perform a transformation. Check out docs to learn more.
Support for nested aggregates
If you specified your associations in relations, you can use a simplified interface for fetching aggregates through repositories. For example if you have users with tasks, and tasks have tags, and you want to load a user aggregate with more levels of nesting, you can now do this:
class UserRepo < ROM::Repository[:users]
relations :tasks, :tags
end
user_repo.aggregate(tasks: :tags)
Check out Repository#aggregate API docs for more information.
Detailed release information
As part of this release following gems have been published:
- rom 3.0.0 CHANGELOG
- rom-sql 1.0.0 CHANGELOG
- rom-repository 1.0.0 CHANGELOG
- rom-rails 0.9.0 CHANGELOG
Please check out rom-rb.org as it was updated with more documentation!
Upgrading
Please do read rom-sql 1.0.0 upgrade guide as it includes useful information. Making the transition should not be difficult, many applications (including big ones), have been already upgraded during beta/RC testing, and it was a smooth process.
If you have problems with the upgrade, please report an issue or ask for help on the discussion forum.
Thank you!
This is a long post, and it barely covers ~20% of what was improved or added, it was a huge effort to get here and I would like to thank all of the contributors, for their PRs, reported issues, and testing beta/rc releases early!
Special thanks go to (in no particular order):
- Nikita Shilnikov, for his fantastic work on schema inferrers, helping a lot with rom-support removal by putting together dry-core and porting all rom gems to use dry-initializer
- Sergey Kukunin for his help with transproc 1.0.0 (yes, we released that too!) and helping with rom-repository mapping pipeline
- Andrew Kozin for his work on dry-initializer which now plays major role in many core objects in rom projects and allowed us to get rid of rom-support
What happens next?
We have big plans for future releases, but hopefully we'll manage to provide more frequent, incremental improvements now that rom-sql and rom-repository are stable. More details will be revealed soon, stay tuned!
I hope you'll find this release useful, if you have problems or any kind of feedback, please report issues or just talk to us.
If you happen to attend RubyConf AU next week, be sure to say hi! :)