4c3ea14c7aabaae064c2b732d40e7c80157c415a
[nit.git] / lib / popcorn / pop_repos.nit
1 # This file is part of NIT ( http://www.nitlanguage.org ).
2 #
3 # Copyright 2016 Alexandre Terrasa <alexandre@moz-code.org>
4 #
5 # Licensed under the Apache License, Version 2.0 (the "License");
6 # you may not use this file except in compliance with the License.
7 # You may obtain a copy of the License at
8 #
9 # http://www.apache.org/licenses/LICENSE-2.0
10 #
11 # Unless required by applicable law or agreed to in writing, software
12 # distributed under the License is distributed on an "AS IS" BASIS,
13 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14 # See the License for the specific language governing permissions and
15 # limitations under the License.
16
17 # Repositories for data management.
18 #
19 # Repositories are used to apply persistence on instances (or **documents**).
20 # Using repositories one can store and retrieve instance in a clean and maintenable
21 # way.
22 #
23 # This module provides the base interface `Repository` that defines the persistence
24 # services available in all kind of repos.
25 # `JsonRepository` factorizes all repositories dedicated to Json data or objects
26 # serializable to Json.
27 #
28 # `MongoRepository` is provided as a concrete example of repository.
29 # It implements all the services from `Repository` using a Mongo database as backend.
30 #
31 # Repositories can be used in Popcorn app to manage your data persistence.
32 # Here an example with a book management app:
33 #
34 # ~~~
35 # # First we declare the `Book` class. It has to be serializable so it can be used
36 # # within a `Repository`.
37 #
38 # import popcorn
39 # import popcorn::pop_repos
40 #
41 # # Serializable book representation.
42 # class Book
43 # serialize
44 # super Jsonable
45 #
46 # # Book uniq ID
47 # var id: String = (new MongoObjectId).id is serialize_as "_id"
48 #
49 # # Book title
50 # var title: String
51 #
52 # # ... Other fields
53 #
54 # redef fun to_s do return title
55 # redef fun ==(o) do return o isa SELF and id == o.id
56 # redef fun hash do return id.hash
57 # redef fun to_json do return serialize_to_json
58 # end
59 #
60 # # We then need to subclass the `MongoRepository` to provide Book specific services.
61 #
62 # # Book repository for Mongo
63 # class BookRepo
64 # super MongoRepository[Book]
65 #
66 # # Find books by title
67 # fun find_by_title(title: String): Array[Book] do
68 # var q = new JsonObject
69 # q["title"] = title
70 # return find_all(q)
71 # end
72 # end
73 #
74 # # The repository can be used in a Handler to manage book in a REST API.
75 #
76 # class BookHandler
77 # super Handler
78 #
79 # var repo: BookRepo
80 #
81 # # Return a json array of all books
82 # #
83 # # If the get parameters `title` is provided, returns a json array of books
84 # # matching the `title`.
85 # redef fun get(req, res) do
86 # var title = req.string_arg("title")
87 # if title == null then
88 # res.json new JsonArray.from(repo.find_all)
89 # else
90 # res.json new JsonArray.from(repo.find_by_title(title))
91 # end
92 # end
93 #
94 # # Insert a new Book
95 # redef fun post(req, res) do
96 # var title = req.string_arg("title")
97 # if title == null then
98 # res.error 400
99 # return
100 # end
101 # var book = new Book(title)
102 # repo.save book
103 # res.json book
104 # end
105 # end
106 #
107 # # Let's wrap it all together in a Popcorn app:
108 #
109 # # Init database
110 # var mongo = new MongoClient("mongodb://localhost:27017/")
111 # var db = mongo.database("tests_app_{100000.rand}")
112 # var coll = db.collection("books")
113 #
114 # # Init app
115 # var app = new App
116 # var repo = new BookRepo(coll)
117 # app.use("/books", new BookHandler(repo))
118 # app.listen("localhost", 3000)
119 # ~~~
120 module pop_repos
121
122 import popcorn::pop_config
123 import serialization
124 import json::serialization
125 import mongodb::queries
126
127 redef class AppConfig
128
129 # Default database host string for MongoDb
130 var default_db_host = "mongodb://localhost:27017/"
131
132 # Default database hostname
133 var default_db_name = "popcorn"
134
135 # MongoDB server used for data persistence
136 var db_host: String is lazy do return value_or_default("db.host", default_db_host)
137
138 # MongoDB DB used for data persistence
139 var db_name: String is lazy do return value_or_default("db.name", default_db_name)
140
141 # Mongo db client
142 var client = new MongoClient(db_host) is lazy
143
144 # Mongo db instance
145 var db: MongoDb = client.database(db_name) is lazy
146
147 redef init from_options(opts) do
148 super
149 var db_host = opts.opt_db_host.value
150 if db_host != null then self["db.host"] = db_host
151 var db_name = opts.opt_db_name.value
152 if db_name != null then self["db.name"] = db_name
153 end
154 end
155
156 redef class AppOptions
157
158 # MongoDb host name
159 var opt_db_host = new OptionString("MongoDb host", "--db-host")
160
161 # MongoDb database name
162 var opt_db_name = new OptionString("MongoDb database name", "--db-name")
163
164 init do
165 super
166 add_option(opt_db_host, opt_db_name)
167 end
168 end
169
170 # A Repository is an object that can store serialized instances.
171 #
172 # Repository is the base class of all kind of persistance processes. It offers
173 # the base CRUD services to save (add/update), find and delete instances.
174 #
175 # Instances are stored in their serialized form. See the `serialization` package
176 # for more documentation.
177 interface Repository[E: Serializable]
178
179 # Kind of queries accepted
180 #
181 # Can be redefined to accept more precise queries depending on the backend used.
182 type QUERY: RepositoryQuery
183
184 # Find an instance by it's `id`
185 #
186 # `id` is an abstract thing at this stage
187 # TODO maybe introduce the `PrimaryKey` concept?
188 fun find_by_id(id: String): nullable E is abstract
189
190 # Find an instance based on `query`
191 fun find(query: QUERY): nullable E is abstract
192
193 # Find all instances based on `query`
194 #
195 # Using `query` == null will retrieve all the document in the repository.
196 fun find_all(query: nullable QUERY, skip, limit: nullable Int): Array[E] is abstract
197
198 # Count instances that matches `query`
199 fun count(query: nullable QUERY): Int is abstract
200
201 # Save an `instance`
202 fun save(instance: E): Bool is abstract
203
204 # Remove the instance with `id`
205 fun remove_by_id(id: String): Bool is abstract
206
207 # Remove the instance based on `query`
208 fun remove(query: nullable QUERY): Bool is abstract
209
210 # Remove all the instances matching on `query`
211 fun remove_all(query: nullable QUERY): Bool is abstract
212
213 # Remove all instances
214 fun clear: Bool is abstract
215
216 # Serialize an `instance` to a String.
217 fun serialize(instance: nullable E): nullable String is abstract
218
219 # Deserialize a `string` to an instance.
220 fun deserialize(string: nullable String): nullable E is abstract
221 end
222
223 # An abstract Query representation.
224 #
225 # Since the kind of query available depends on the database backend choice or
226 # implementation, this interface is used to provide a common type to all the
227 # queries.
228 #
229 # Redefine `Repository::QUERY` to use your own kind of query.
230 interface RepositoryQuery end
231
232 # A Repository for JsonObjects.
233 #
234 # As for document oriented databases, Repository can be used to store and retrieve
235 # Json object.
236 # Serialization from/to Json is used to translate from/to nit instances.
237 #
238 # See `MongoRepository` for a concrete implementation example.
239 abstract class JsonRepository[E: Serializable]
240 super Repository[E]
241
242 redef fun serialize(item) do
243 if item == null then return null
244 var stream = new StringWriter
245 var serializer = new RepoSerializer(stream)
246 serializer.serialize item
247 stream.close
248 return stream.to_s
249 end
250
251 redef fun deserialize(string) do
252 if string == null then return null
253 var deserializer = new JsonDeserializer(string)
254 return deserializer.deserialize.as(E)
255 end
256 end
257
258 private class RepoSerializer
259 super JsonSerializer
260
261 # Remove caching when saving refs to db
262 redef fun serialize_reference(object) do serialize object
263 end
264
265 # A Repository that uses MongoDB as backend.
266 #
267 # ~~~
268 # import popcorn
269 # import popcorn::pop_repos
270 #
271 # # First, let's create a User abstraction:
272 #
273 # # Serializable user representation.
274 # class User
275 # super RepoObject
276 # serialize
277 #
278 # # User login
279 # var login: String
280 #
281 # # User password
282 # var password: String is writable
283 #
284 # redef fun to_s do return login
285 # end
286 #
287 # # We then need to subclass the `MongoRepository` to provide User specific services:
288 #
289 # # User repository for Mongo
290 # class UserRepo
291 # super MongoRepository[User]
292 #
293 # # Find a user by its login
294 # fun find_by_login(login: String): nullable User do
295 # var q = new JsonObject
296 # q["login"] = login
297 # return find(q)
298 # end
299 # end
300 #
301 # # The repository can then be used with User instances:
302 #
303 # # Init database
304 # var mongo = new MongoClient("mongodb://localhost:27017/")
305 # var db = mongo.database("tests")
306 # var coll = db.collection("test_pop_repo_{100000.rand}")
307 #
308 # # Create a user repo to store User instances
309 # var repo = new UserRepo(coll)
310 #
311 # # Create some users
312 # repo.save(new User("Morriar", "1234"))
313 # repo.save(new User("Alex", "password"))
314 #
315 # assert repo.find_all.length == 2
316 # assert repo.find_by_login("Morriar").password == "1234"
317 # repo.clear
318 # assert repo.find_all.length == 0
319 # ~~~
320 class MongoRepository[E: Serializable]
321 super JsonRepository[E]
322
323 redef type QUERY: JsonObject
324
325 # MongoDB collection used to store objects
326 var collection: MongoCollection
327
328 redef fun find_by_id(id) do
329 var query = new JsonObject
330 query["_id"] = id
331 return find(query)
332 end
333
334 redef fun find(query) do
335 var res = collection.find(query)
336 if res == null then return null
337 return deserialize(res.to_json)
338 end
339
340 redef fun find_all(query, skip, limit) do
341 var res = new Array[E]
342 for e in collection.find_all(query or else new JsonObject, skip, limit) do
343 res.add deserialize(e.to_json).as(E)
344 end
345 return res
346 end
347
348 redef fun count(query) do
349 return collection.count(query or else new JsonObject)
350 end
351
352 redef fun save(item) do
353 var json = serialize(item).as(String)
354 var obj = json.parse_json.as(JsonObject)
355 return collection.save(obj)
356 end
357
358 redef fun remove_by_id(id) do
359 var query = new JsonObject
360 query["_id"] = id
361 return remove(query)
362 end
363
364 redef fun remove(query) do
365 return collection.remove(query or else new JsonObject)
366 end
367
368 redef fun remove_all(query) do
369 return collection.remove_all(query or else new JsonObject)
370 end
371
372 redef fun clear do return collection.drop
373
374 # Perform an aggregation query over the repo.
375 fun aggregate(pipeline: JsonArray): Array[E] do
376 var res = new Array[E]
377 for obj in collection.aggregate(pipeline) do
378 var instance = deserialize(obj.to_json)
379 if instance == null then continue
380 res.add instance
381 end
382 return res
383 end
384 end
385
386 # Base serializable entity that can go into a JsonRepository
387 #
388 # Provide boiler plate implementation of all object serializable to json.
389 #
390 # `id` is used as a primary key for `find_by_id`.
391 #
392 # Subclassing RepoObject makes it easy to create a serializable class:
393 # ~~~
394 # import popcorn::pop_repos
395 #
396 # class Album
397 # super RepoObject
398 # serialize
399 #
400 # var title: String
401 # var price: Float
402 # end
403 # ~~~
404 #
405 # Do not forget the `serialize` annotation else the fields will not be serialized.
406 #
407 # It is also possible to redefine the `id` primary key to use your own:
408 # ~~~
409 # import popcorn::pop_repos
410 #
411 # class Order
412 # super RepoObject
413 # serialize
414 #
415 # redef var id = "order-{get_time}"
416 #
417 # # ...
418 #
419 # end
420 # ~~~
421 abstract class RepoObject
422 super Jsonable
423 serialize
424
425 # `self` unique id.
426 #
427 # This attribute is serialized under the key `_id` to be used
428 # as primary key by MongoDb
429 var id: String = (new MongoObjectId).id is writable, serialize_as "_id"
430
431 # Base object comparison on ID
432 #
433 # Because multiple deserialization can exists of the same instance,
434 # we use the ID to determine if two object are the same.
435 redef fun ==(o) do return o isa SELF and id == o.id
436
437 redef fun hash do return id.hash
438 redef fun to_s do return id
439 redef fun to_json do return serialize_to_json
440 end
441
442 # JsonObject can be used as a `RepositoryQuery`.
443 #
444 # See `mongodb` lib.
445 redef class JsonObject
446 super RepositoryQuery
447 end