13 KiB
Basic Usage
Overview
There are several categories of operations that can be accomplished against documents.
- Count returns the number of documents matching some criteria
- Exists returns true if any documents match the given criteria
- Insert (
Document.insert
) adds a new document, failing if the ID field is not unique - Save (
Document.save
) adds a new document, updating an existing one if the ID is already present ("upsert") - Update updates an existing document, doing nothing if no documents satisfy the criteria
- Patch updates a portion of an existing document, doing nothing if no documents satisfy the criteria
- Find returns the documents matching some criteria as domain objects
- Json returns documents matching some criteria as a JSON string, or writes that text directly to a
PrintWriter
- RemoveFields removes fields from documents matching some criteria
- Delete removes documents matching some criteria
Insert
and Save
were the only two that don't mention criteria. For the others, "some criteria" can be defined a few different ways:
- all references all documents in the table; applies to Count and Find
- byId looks for a single document on which to operate; applies to all but Count
- byFields uses JSON field comparisons to select documents for further processing (PostgreSQL will use a numeric comparison if the field value is numeric, or a string comparison otherwise; SQLite will do its usual best-guess on types); applies to all but Update
- byContains (PostgreSQL only) uses a JSON containment query (the
@>
operator) to find documents where the given sub-document occurs (think of this as an=
comparison based on one or more properties in the document; looking for hotels with{ "country": "USA", "rating": 4 }
would find all hotels with a rating of 4 in the United States); applies to all but Update - byJsonPath (PostgreSQL only) uses a JSON patch match query (the
@?
operator) to make specific queries against a document's structure (it also supports more operators than a containment query; to find all hotels rated 4 or higher in the United States, we could query for"$ ? (@.country == \"USA\" && @.rating > 4)"
); applies to all but Update
Finally, Find
also has firstBy*
implementations for all supported criteria types, and Find.*Ordered
implementations to sort the results in the database.
Saving Documents
Note
All code samples are in Java unless otherwise noted. Also, they use the connection-creating functions for clarity; see below for notes on how these names translate to
Connection
extensions.
The library provides three different ways to save data. The first equates to a SQL INSERT
statement, and adds a single document to the repository.
Room room = new Room(/* ... */);
// Parameters are table name and document
Document.insert("room", room);
The second is save
; and inserts the data it if does not exist and replaces the document if it does exist (what some call an "upsert"). It utilizes the ON CONFLICT
syntax to ensure an atomic statement. Its parameters are the same as those for insert
.
The third equates to a SQL UPDATE
statement. Update
applies to a full document and is usually used by ID, while Patch
is used for partial updates and may be done by field comparison, JSON containment, or JSON Path match. For a few examples, let's begin with a query that may back the "edit hotel" page. This page lets the user update nearly all the details for the hotel, so updating the entire document would be appropriate.
Hotel hotel = Find.byId("hotel", hotelId, Hotel.class);
if (hotel != null) {
// update hotel properties from the posted form
Update.byId("hotel", hotel.getId(), hotel);
}
For the next example, suppose we are upgrading our hotel, and need to take rooms 221-240 out of service. We can utilize a patch via JSON Path to accomplish this.
Patch.byJsonPath("room",
"$ ? (@.hotelId == \"abc\" && (@.roomNumber >= 221 && @.roomNumber <= 240)",
Map.of("inService", false));
Note
We are ignoring the current reservations, end date, etc. This is very naïve example!
JSON Path queries are only available for PostgreSQL. Both PostgreSQL and SQLite could accomplish this using the Between
comparison and a byFields
query:
// Parameters are table name, fields to match, and patch to apply;
// there are others, but they are optional
Patch.byFields("room", List.of(Field.between("roomNumber", 221, 240)),
Map.of("inService", false));
Scala programs can use its ::
operator to provide an immutable list:
// Scala
Patch.byFields("room", Field.between("roomNumber", 221, 240) :: Nil,
Map.Map1("inService", false))
Note
These examples use
Map
s as the patch documents, as that is a convenient way to update one or more fields in a document. As patches are serialized to JSON, this can be used to update a sub-document (a domain object which is a property in a larger document/object).
This could also be done using multiple fields, such as Field.greaterOrEqual
and Field.lessOrEqual
, providing FieldMatch.All
after the patch object; there are many different ways to do things!
There are more complex ways to update data, including custom queries; that will be detailed in the Advanced Usage section.
Retrieving Documents
As Domain Items
Functions to find documents start with Find.
. There are variants to find all documents in a table, find by ID, find by JSON field comparisons, find by JSON containment, or find by JSON Path. The hotel update example above utilizes an ID lookup; the descriptions of JSON containment and JSON Path show examples of the criteria used to retrieve using those techniques.
Find.*Ordered
methods append an ORDER BY
clause to the query that will sort the results in the database. These take, as their last parameter, a collection of Field
items; a .named
static method allows for field creation for these names. Within these names, prefixing the name with n:
will tell PostgreSQL to sort this field numerically rather than alphabetically; it has no effect in SQLite (it does its own type coercion). Prefixing the name with i:
will do a case-insensitive sort. Adding " DESC" at the end will sort high-to-low instead of low-to-high.
Choose Your Adventure
Document retrieval is the reason there are three distinct implementations of this library (groovy
shares core
's implementation). Java's type-erased generics require a Class
for documents to be deserialized. Scala's implicit ClassTag
and Kotlin's reified generics do not require a Class
instance as a parameter, but will typically require a generic type when they are called. Scala's collection types will convert back and forth to Java's and Kotlin's, but littering .asJava
at the end of collections is a terrible developer experience.
Rather than try to take one pass through the Find
methods, calling out all the different ways they are implemented, we'll do a pass for each implementation.
Core (Java, Groovy, reflection-based Kotlin)
Methods that attempt to return a single document will return an instance of Java's Optional<T>
class. Methods that return a list of documents return a Kotlin List<T>
(immutable, but otherwise behaves as one would expect a Java list to behave). Parameters which can have multiple values expect a Kotlin Sequence<T>
, which most Java collection types satisfy (ArrayList<T>
, HashSet<T>
, etc.).
Each method will have, as one of its later parameters, a Class
instance. This is used by the deserializer to select the class which gets created. For example, Find.byId
's parameters are table name, document ID, and Class
to return.
Scala
Methods that attempt to return a single document will return an instance of Scala's Option[A]
type. Methods that return a list of documents return Scala's immutable List[Doc]
. Parameters which can have multiple values expect a Scala Seq[A]
.
Each Find.
method requires a generic type parameter to indicate what type of object should be returned. To select a hotel by its ID, for example:
// Scala
val hotel = Find.byId[Hotel](tableName, hotelId)
KotlinX
Methods that attempt to return a single document will return a nullable type. Others are the same as the core
module.
Each Find.
method requires a generic type parameter, and requires inline
on the method definition to support the reified type parameter (and compiler-implemented serialization). This choice can cascade throughout the calling application, but the compiler will be happy to tell you if the enclosing context needs to be marked as inline
.
As JSON
All Find
methods and functions have two corresponding Json
methods. While the input types for these methods also vary based on the implementation, these all return either String
s or void
.
- The first set return the expected document(s) as a
String
, and will always return valid JSON. Single-document queries with nothing found will return{}
, while zero-to-many queries will return[]
if no documents match the given criteria. - The second set are prefixed with
write
, and take aPrintWriter
immediately after the table name parameter. These functions write results to the given writer as they are retrieved from the database, instead of accumulating them all and returning aString
. This can be useful for JSON API scenarios, as it can be written directly to a servlet output stream.
Note
This library does no flushing of the underlying stream. Applications should handle that after calling
Json.write*
or configure thePrintWriter
to auto-flush.
Deleting Documents
Functions to delete documents start with Delete.
. Document deletion is supported by ID, JSON field comparison, JSON containment, or JSON Path match. The pattern is the same as for finding or partially updating. (There is no library method provided to delete all documents, though deleting by JSON field comparison where a non-existent field is null would accomplish this.)
Counting Documents
Functions to count documents start with Count.
. Documents may be counted by a table in its entirety, by JSON field comparison, by JSON containment, or by JSON Path match. (Counting by ID is an existence check!)
Document Existence
Functions to check for existence start with Exists.
. Documents may be checked for existence by ID, JSON field comparison, JSON containment, or JSON Path match.
What / How Cross-Reference
The table below shows which commands are available for each access method. (X = supported for both, P = PostgreSQL only)
Operation | all |
byId |
byFields |
byContains |
byJsonPath |
firstByFields |
firstByContains |
firstByJsonPath |
---|---|---|---|---|---|---|---|---|
Count |
X | X | P | P | ||||
Exists |
X | X | P | P | ||||
Find / Json |
X | X | X | P | P | X | P | P |
Patch |
X | X | P | P | ||||
RemoveFields |
X | X | P | P | ||||
Delete |
X | X | P | P |
Document.insert
, Document.save
, and Update.*
operate on single documents.
Extension Methods
Extension methods are defined in the .extensions
package for each library. They can be selectively imported, or all can be imported at once. (Groovy extension methods should be visible just from the module being installed.)
Here is how the names are translated to those extensions methods:
- Most have the name of the class plus the name of the method - e.g.,
Count.all
becomescountAll
,Find.byFieldsOrdered
becomesfindByFieldsOrdered
, etc. Document
andDefinition
methods are defined using their existing name - e.g.,Document.insert
is simplyinsert
,Definition.ensureTable
isensureTable
, etc.Json.write*
functions begin withwriteJson
- e.g.,Json.writeByFields
becomeswriteJsonByFields
, etc.
As extensions, these will be visible on the instance of the Connection
object in the application.