Releases: scalamolecule/molecule
v0.15.0 Unenforced FK constraints
Using Molecule with sbt-molecule
1.11.0 no longer enforces foreign key constraints for SQL databases.
Power and orphans
This is a double-edged sword since we can then delete any entity we want now.
But if another entity references it, that reference id becomes an orphan reference pointing nowhere (to the deleted entity). So we get freedom to delete what we want at the cost of risking having orphaned reference ids hanging around.
Avoiding orphan refs
To avoid orphan ref ids, we can either
- delete orphan ref ids manually,
- add a foreign key constraint to our database schema or
- not care
Adding a foreign key constraint
In the generated SQL schemas, you can copy any foreign key constraints you'd like to enforce and copy them to your live schema. Here's an example from an H2 schema:
// ALTER TABLE A ADD CONSTRAINT `_a` FOREIGN KEY (a) REFERENCES A (id);
v0.14.1 Transaction handling
3 new transaction handling methods are introduced for SQL databases on the jvm side.
Examples shown with the sync
api - could as well be async
, zio
or io
apis
Transaction bundle
Transact multiple actions in one commit with transact(<actions...>)
transact(
Ns.int(1).save,
Ns.int.insert(2, 3),
Ns(1).delete,
Ns(3).int.*(10).update,
) // result: List(2, 30)
It's easier to write than calling .transact
on all 4 actions
Ns.int(1).save.transact
Ns.int.insert(2, 3).transact
Ns(1).delete.transact
Ns(3).int.*(10).update.transact
Rollback all on any error
transact(<actions..>)
rolls back all actions if any fails. If for instance Type.int
has a validation defined in the Data Model that requires integers to be larger than 1, then both of the following actions will be rolled back:
try {
transact(
Type.int.insert(4, 5),
Type.int(1).save, // not valid, triggers rollback of both actions
)
} catch {
case ValidationErrors(errorMap) =>
errorMap.head._2.head ==>
s"""Type.int with value `1` doesn't satisfy validation:
|_ > 2
|""".stripMargin
}
Unit of work
When you need to transact multiple actions and also at the same time query things, you can use unitOfWork
:
// Initial balance in two bank accounts
Ns.s("fromAccount").int(100).save.transact
Ns.s("toAccount").int(50).save.transact
try {
unitOfWork {
Ns.s_("fromAccount").int.-(200).update.transact
Ns.s_("toAccount").int.+(200).update.transact
// Check that fromAccount had sufficient funds
if (Ns.s_("fromAccount").int.query.get.head < 0) {
// Abort all transactions in this unit of work
throw new Exception(
"Insufficient funds in fromAccount..."
)
}
}
} catch {
case e: Exception =>
// Do something with failure...
e.getMessage ==> "Insufficient funds in fromAccount..."
}
// No data transacted
Ns.s.int.query.get ==> List(
("fromAccount", 100),
("toAccount", 50),
)
Savepoints
Rollback scoped actions within a unitOfWork
with savepoint
:
Ns.int.insert(1 to 4).transact
Ns.int(count).query.get.head ==> 4
unitOfWork {
savepoint { sp =>
// Delete all
Ns.int_.delete.transact
Ns.int(count).query.get.head ==> 0
// Roll back delete action within savepoint scope
sp.rollback()
Ns.int(count).query.get.head ==> 4
}
}
// Nothing deleted
Ns.int(count).query.get.head ==> 4
Throwing an exception instead of calling sp.rollback()
has the same effect.
Using savepoints can be a convenient way of rolling back only parts of a larger body of transactions/unitOfWork
. Savepoints can even be nested (see tests).
v0.13.0 Computed Attribute values
Molecule can now update and query computed attribute values.
Computed update operations
A classical example is increasing a counter where the current value is increased by 1:
// Current counter == c
val (id, c) = Game.id.counter.query.get.head
// Increase counter value with 1
Game(id).counter.+(1).update.transact
// Counter has increased
Game.counter.query.get.head == c + 1
Number updates
If a number
attribute has value 4
, we can see what update operations we can perform on it:
// Arithmetic
Something(id).number.+(1).update.transact // 4 + 1 => 5
Something(id).number.-(1).update.transact // 4 - 1 => 3
Something(id).number.*(2).update.transact // 4 * 2 => 8
Something(id).number./(2).update.transact // 4 / 2 => 2
// 4 => -4
// -4 => 4
Something(id).number.negate.update.transact
// 4 => 4
// -4 => 4
Something(id).number.abs.update.transact
// 4 => -4
// -4 => -4
Something(id).number.absNeg.update.transact
// 4.4 => 5
// -4.4 => -4
Something(id).number.ceil.update.transact
// 4.4 => 4
// -4.4 => -5
Something(id).number.floor.update.transact
String updates
With a string
attribute having value Hello
we can do the following update operations:
Something(id).string.+(" World").update.transact // => "Hello World"
Something(id).string.prepend("Say ").update.transact // => "Say Hello"
// Substring from start to end indexes
Something(id).string.substring(2, 4).update.transact // => "ll"
Something(id).string.replaceAll("l", "_").update.transact // => "He__o"
// regex
Something(id).string.replaceAll("[h-z]", "-").update.transact // => "He---"
Something(id).string.toLower.update.transact // => "hello"
Something(id).string.toUpper.update.transact // => "HELLO"
Boolean updates
With a bool
attribute having value true
we can do the following update operations:
// AND
Something(id).bool.&&(true).update.transact // => true
Something(id).bool.&&(false).update.transact // => false
// OR
Something(id).bool.||(true).update.transact // => true
Something(id).bool.||(false).update.transact // => true
// NOT
Something(id).bool.!.update.transact // => false
Computed query filters
Some operations on numbers were added to generate computed query filters based on the attribute value:
Integers
Int
, Long
, BigInt
, Short
, Byte
Something.int.even.query.get // List(0, 2, 4)
Something.int.odd.query.get // List(1, 3, 5)
// Modulo
Something.int.%(3, 0).query.get // List(0, 3)
Something.int.%(3, 1).query.get // List(1, 4)
Something.int.%(3, 2).query.get // List(2, 5)
Decimals
Double
, BigDecimal
, Float
Something.dec.query.get // List(1.1, 2.2, 3.3)
Something.dec.floor.query.get // List(1, 2, 3)
Something.dec.ceil.query.get // List(2, 3, 4)
v0.12.1 Map attr ops aligned
Map attributes can now be filtered by key or value in a consistent way that corresponds to operations on a Scala Map
. This should make working with Map attributes in Molecule feel natural for a Scala programmer.
Examples
We'll use the following example of a Map attribute capturing Shostakovich's name in different languages and see how we can use the operations available.
val namesMap = Map(
"en" -> "Shostakovich",
"de" -> "Schostakowitsch",
"fr" -> "Chostakovitch",
)
Person.name("Shostakovich").langNames(namesMap).save.transact
Filter by key
attr(key)
Retrieve a value by applying a key to a mandatory Map attribute:
// Get German spelling of Shostakovich
Person.langNames("de").query.get ==> List("Schostakowitsch")
Equivalent to calling apply
on a Scala Map
namesMap("de") ==> "Schostakowitsch"
Looking up a non-existing key simply returns an empty result
Person.langNames("xx").query.get ==> Nil
attr_(key)
Ensure a certain key is present by applying the key to a tacit Map attribute:
// Get (English) name having a German spelling
Person.name.langNames_("de").query.get ==> List("Shostakovich")
Person.name.langNames_("xx").query.get ==> Nil
attr_?(key)
Retrieve an optional value by applying a key to an optional Map attribute:
Person.langNames_?("de").query.get.head ==> Some("Schostakowitsch")
Person.langNames_?("xx").query.get.head ==> None
Equivalent to calling get
on a Scala Map
namesMap.get("de") ==> Some("Schostakowitsch")
namesMap.get("xx") ==> None
attr.not(keys)
Get Maps not having a certain key by applying the key to not
of a mandatory Map attribute:
// Get langNames maps without a Spanish spelling
Person.langNames.not("es").query.get ==> List(namesMap)
// Get langNames maps without an English spelling
Person.langNames.not("en").query.get ==> Nil
Multiple keys kan be applied as varargs or a Seq
// Get langNames maps without Spanish or Chinese spelling
Person.langNames.not("es", "cn").query.get ==> List(namesMap)
Person.langNames.not(Seq("es", "cn")).query.get ==> List(namesMap)
// One of the keys exists, so no match
Person.langNames.not(List("es", "en")).query.get ==> Nil
attr_.not(keys)
Match Maps not having a certain key by applying the key to not
of a tacit Map attribute:
// Match langNames maps without a Spanish spelling
Person.name.langNames_.not("es").query.get ==> List("Shostakovich")
// Match langNames maps without an English spelling
Person.name.langNames_.not("en").query.get ==> Nil
Multiple keys kan be applied as varargs or a Seq
// Match langNames maps without Spanish or Chinese spelling
Person.name.langNames_.not("es", "cn").query.get ==> List("Shostakovich")
Person.name.langNames_.not(Seq("es", "cn")).query.get ==> List("Shostakovich")
// One of the keys exists, so no match
Person.name.langNames_.not(List("es", "en")).query.get ==> Nil
Filter by value
attr.has(values)
Return Maps that have certain values with has
on a mandatory Map attribute:
// Get map if it has a spelling value of "Chostakovitch"
Person.langNames.has("Chostakovitch").query.get ==> List(namesMap)
// Get map if it has a spelling
// value of "Chostakovitch" or "Sjostakovitj"
Person.langNames.has("Chostakovitch", "Sjostakovitj").query.get ==> List(namesMap)
attr_.has(values)
Match Maps that have certain values with has
on a tacit Map attribute:
// Match map if it has a spelling value of "Chostakovitch"
Person.name.langNames_.has("Chostakovitch").query.get ==> List("Shostakovich")
Person.langNames_.has("Chostakovitch", "Sjostakovitj").query.get ==> List("Shostakovich")
Likewise we can ask for Map attributes without certain values:
attr.hasNo(values)
Return Maps without certain values using hasNo
on a mandatory Map attribute:
// Get map if it doesn't have a spelling of "Sjostakovitj"
Person.langNames.hasNo("Sjostakovitj").query.get ==> List(namesMap)
// Get map if it doesn't have a spelling
// value of "Chostakovitch" or "Sjostakovitj"
Person.langNames.hasNo("Chostakovitch", "Sjostakovitj").query.get ==> Nil
attr_.hasNo(values)
Match Maps without certain values using hasNo
on a tacit Map attribute:
// Match map if it doesn't have a spelling value of "Sjostakovitj"
Person.name.langNames_.hasNo("Sjostakovitj").query.get ==> List("Shostakovich")
// Match map if it doesn't have a spelling
// value of "Chostakovitch" or "Sjostakovitj"
Person.langNames_.hasNo("Chostakovitch", "Sjostakovitj").query.get ==> Nil
v0.11.0 Cats effect IO API
Molecule now supports returning data wrapped in a cats.effect.IO
by importing the relevant database and API. So now there are 4 APIs to choose from:
Synchronous
import molecule.sql.postgres.sync._
val persons: List[(String, Int, String)] =
Person.name.age.Address.street.query.get
Asynchronous (Future)
import molecule.sql.postgres.async._
val persons: Future[List[(String, Int, String)]] =
Person.name.age.Address.street.query.get
ZIO
import molecule.sql.postgres.zio._
val persons: ZIO[Conn, MoleculeError, List[(String, Int, String)]] =
Person.name.age.Address.street.query.get
IO
import molecule.sql.postgres.io._
val persons: cats.effect.IO[List[(String, Int, String)]] =
Person.name.age.Address.street.query.get
Same molecules
As you can see the molecules are identical for each API. The import determines the return type. Likewise, transaction reports are wrapped as the queries for each API.
v0.10.1 RPC bug fixes
Fixing correct resolution in RPC calls when an attribute name collides with a reserved keyword of a database.
v0.10.0 Optional ref (left join)
Molecule now lets you make an optional cardinality-one reference to another namespace with ?
. Data is returned as an Option
with a value or tuple of ref attribute value(s):
A.i(1).save.transact
// Optional card-one ref (SQL left join) - no ref values as None
A.i.B.?(B.i).query.get ==> List(
(1, None),
)
A.i(2).B.i(3).s("b").save.transact
// Optional card-one ref (SQL left join) - values in optional tuple
A.i.B.?(B.i.s).query.get ==> List(
(1, None),
(2, Some((3, "b"))),
)
The query translates to the following SQL:
SELECT DISTINCT
A.i, B.i, B.s
FROM A
LEFT JOIN B ON A.b = B.id
WHERE
A.i IS NOT NULL;
Nested optional refs
We can "nest" optional references, also in inserts:
A.i.B.?(B.s.i.C.?(C.s.i)).insert(List(
(1, None),
(2, Some(("b", 2, None))),
(3, Some(("b", 3, Some(("c", 3))))),
)).transact
A.i.B.?(B.s.i.C.?(C.s.i)).query.get ==> List(
(1, None),
(2, Some(("b", 2, None))),
(3, Some(("b", 3, Some(("c", 3)))))
)
Which translates to 2 left joins, A -> B and B -> C
SELECT DISTINCT
A.i, B.s, B.i, C.s, C.i
FROM A
LEFT JOIN B ON A.b = B.id
LEFT JOIN C ON B.c = C.id
WHERE
A.i IS NOT NULL;
Adjacent optional refs
Use "adjacent" optional ref to make multiple optional refs/left joins from the initial namespace (A). Notice how a new definition starts outside the previous optional ref parenthesis.
A.i
.B.?(B.i.s)
.C.?(C.s.i).insert(List(
(1, None, None),
(2, Some((20, "b")), None),
(3, None, Some(("c", 300))),
(4, Some((40, "b")), Some(("c", 400))),
)).transact
A.i
.B.?(B.i.s)
.C.?(C.s.i).query.get ==> List(
(1, None, None),
(2, Some((20, "b")), None),
(3, None, Some(("c", 300))),
(4, Some((40, "b")), Some(("c", 400))),
)
Which translates to 2 left joins, A -> B and A -> C
SELECT DISTINCT
A.i, B.i, B.s, C.s, C.i
FROM A
LEFT JOIN B ON A.b = B.id
LEFT JOIN C ON A.c = C.id
WHERE
A.i IS NOT NULL;
In Datomic optional ref data is pulled (dummy default "__none__" value used for consistent output arities)
Nested:
[:find ?b
(
pull ?id1 [
{(:A/b :default "__none__") [
(:B/s :default "__none__")
(:B/i :default "__none__")
{(:B/c :default "__none__") [
(:C/s :default "__none__")
(:C/i :default "__none__")
]}
]
}
]
)
:where [?a :A/i ?b]
[(identity ?a) ?id1]]
Adjacent:
[:find ?b
(
pull ?id1 [
{(:A/b :default "__none__") [
(:B/i :default "__none__")
(:B/s :default "__none__")
]}
]
)
(
pull ?id2 [
{(:A/c :default "__none__") [
(:C/s :default "__none__")
(:C/i :default "__none__")
]}
]
)
:where [?a :A/i ?b]
[(identity ?a) ?id1]
[(identity ?a) ?id2]]
v0.9.1 Scala 3 supported
Molecule now fully supports Scala 3.
A bug in the sbt-molecule plugin missed to pack new Scala 3 .tasty
files into the generated jar file with Molecule boilerplate code. Now that it does, everything works like a charm.
Make sure to add the latest version of sbt-molecule in your project/plugins.sbt
:
addSbtPlugin("org.scalamolecule" % "sbt-molecule" % "1.8.1")
v0.9.0 SQlite
This is a big release with a lot of fundamental changes.
Letting go of MongDB
Realising that Molecule likely won't appeal to the schema-less use cases of Mongo. Besides, Mongo with it's two data models (embedded/referenced) is a nightmare to implement. Possible, but likely not worth it.
Some background examples of thoughts against Mongo:
https://www.reddit.com/r/PostgreSQL/comments/19bkn8b/doubt_regarding_postgresql_vs_mongodb/
https://www.reddit.com/r/programming/comments/15qtfvf/goodbye_mongodb/
And an older one:
https://news.ycombinator.com/item?id=6712703
Welcoming SQlite
SQlite is now implemented in Molecule.
General improvements
- Improved Map api and key/value handling.
- More powerful and clear update/upsert semantics allowing updating across relationships!
- Distinction between Seqs of all primitive types except Bytes that is saved in an Array for serialization etc.
- General focusing of expression api.
- Aggregation of collection types dropped to keep things simple and focused.
- RawQuery now return List[List[Any]] - dropping futile attempt at returning a typed result. Raw is raw.
v0.8.0 MongoDB
MongoDB added with full implementation of the SPI.