diametric 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- data/.gitignore +22 -0
- data/Gemfile +14 -0
- data/Guardfile +8 -0
- data/Jarfile +6 -0
- data/Jarfile.lock +126 -0
- data/LICENSE.txt +22 -0
- data/README.md +215 -0
- data/Rakefile +17 -0
- data/TODO.org +12 -0
- data/diametric.gemspec +31 -0
- data/lib/diametric.rb +9 -0
- data/lib/diametric/entity.rb +339 -0
- data/lib/diametric/persistence/common.rb +29 -0
- data/lib/diametric/persistence/peer.rb +107 -0
- data/lib/diametric/persistence/rest.rb +88 -0
- data/lib/diametric/query.rb +157 -0
- data/lib/diametric/version.rb +3 -0
- data/lib/jrclj.rb +63 -0
- data/spec/diametric/entity_spec.rb +128 -0
- data/spec/diametric/persistence/peer_spec.rb +36 -0
- data/spec/diametric/persistence/rest_spec.rb +30 -0
- data/spec/diametric/query_spec.rb +63 -0
- data/spec/integration_spec.rb +67 -0
- data/spec/spec_helper.rb +53 -0
- data/spec/support/persistence_examples.rb +68 -0
- metadata +164 -0
data/diametric.gemspec
ADDED
@@ -0,0 +1,31 @@
|
|
1
|
+
# -*- encoding: utf-8 -*-
|
2
|
+
lib = File.expand_path('../lib', __FILE__)
|
3
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
4
|
+
require 'diametric/version'
|
5
|
+
|
6
|
+
Gem::Specification.new do |gem|
|
7
|
+
gem.name = "diametric"
|
8
|
+
gem.version = Diametric::VERSION
|
9
|
+
gem.authors = ["Clinton N. Dreisbach"]
|
10
|
+
gem.email = ["crnixon@gmail.com"]
|
11
|
+
gem.summary = %q{ActiveModel for Datomic}
|
12
|
+
gem.description = <<EOF
|
13
|
+
Diametric is a library for building schemas, queries, and transactions
|
14
|
+
for Datomic from Ruby objects. It is also used to map Ruby objects
|
15
|
+
as entities into a Datomic database.
|
16
|
+
EOF
|
17
|
+
gem.homepage = "https://github.com/crnixon/diametric"
|
18
|
+
|
19
|
+
gem.files = `git ls-files`.split($/)
|
20
|
+
gem.executables = gem.files.grep(%r{^bin/}).map{ |f| File.basename(f) }
|
21
|
+
gem.test_files = gem.files.grep(%r{^(test|spec|features)/})
|
22
|
+
gem.require_paths = ["lib"]
|
23
|
+
|
24
|
+
gem.add_dependency 'edn', '~> 1.0'
|
25
|
+
gem.add_dependency 'activesupport', '>= 3.0.0'
|
26
|
+
gem.add_dependency 'activemodel', '>= 3.0.0'
|
27
|
+
gem.add_dependency 'datomic-client', '~> 0.4.1'
|
28
|
+
gem.add_dependency 'lock_jar', '~> 0.7.2'
|
29
|
+
|
30
|
+
gem.extensions = ['Rakefile']
|
31
|
+
end
|
data/lib/diametric.rb
ADDED
@@ -0,0 +1,9 @@
|
|
1
|
+
require "diametric/version"
|
2
|
+
require "diametric/entity"
|
3
|
+
require "diametric/query"
|
4
|
+
|
5
|
+
# Diametric is a library for building schemas, queries, and
|
6
|
+
# transactions for Datomic from Ruby objects. It is also used to map
|
7
|
+
# Ruby objects as entities into a Datomic database.
|
8
|
+
module Diametric
|
9
|
+
end
|
@@ -0,0 +1,339 @@
|
|
1
|
+
require "bigdecimal"
|
2
|
+
require "edn"
|
3
|
+
require 'active_support/core_ext'
|
4
|
+
require 'active_support/inflector'
|
5
|
+
require 'active_model'
|
6
|
+
|
7
|
+
module Diametric
|
8
|
+
|
9
|
+
# +Diametric::Entity+ is a module that, when included in a class,
|
10
|
+
# gives it the ability to generate Datomic schemas, queries, and
|
11
|
+
# transactions, and makes it +ActiveModel+ compliant.
|
12
|
+
#
|
13
|
+
# While this allows you to use this anywhere you would use an
|
14
|
+
# +ActiveRecord::Base+ model or another +ActiveModel+-compliant
|
15
|
+
# instance, it _does not_ include persistence. The +Entity+ module
|
16
|
+
# is primarily made of pure functions that take their receiver (an
|
17
|
+
# instance of the class they are included in) and return data that
|
18
|
+
# you can use in Datomic. +Entity+ can be best thought of as a data
|
19
|
+
# builder for Datomic.
|
20
|
+
#
|
21
|
+
# Of course, you can combine +Entity+ with one of the two available
|
22
|
+
# Diametric persistence modules for a fully persistent model.
|
23
|
+
#
|
24
|
+
# When +Entity+ is included in a class, that class is extended with
|
25
|
+
# {ClassMethods}.
|
26
|
+
#
|
27
|
+
# @!attribute dbid
|
28
|
+
# The database id assigned to the entity by Datomic.
|
29
|
+
# @return [Integer]
|
30
|
+
module Entity
|
31
|
+
# Conversions from Ruby types to Datomic types.
|
32
|
+
VALUE_TYPES = {
|
33
|
+
Symbol => "keyword",
|
34
|
+
String => "string",
|
35
|
+
Integer => "long",
|
36
|
+
Float => "float",
|
37
|
+
BigDecimal => "bigdec",
|
38
|
+
DateTime => "instant",
|
39
|
+
URI => "uri"
|
40
|
+
}
|
41
|
+
|
42
|
+
@temp_ref = -1000
|
43
|
+
|
44
|
+
def self.included(base)
|
45
|
+
base.send(:extend, ClassMethods)
|
46
|
+
base.send(:extend, ActiveModel::Naming)
|
47
|
+
base.send(:include, ActiveModel::Conversion)
|
48
|
+
base.send(:include, ActiveModel::Dirty)
|
49
|
+
|
50
|
+
base.class_eval do
|
51
|
+
@attributes = []
|
52
|
+
@partition = :"db.part/user"
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
def self.next_temp_ref
|
57
|
+
@temp_ref -= 1
|
58
|
+
end
|
59
|
+
|
60
|
+
# @!attribute [rw] partition
|
61
|
+
# The Datomic partition this entity's data will be stored in.
|
62
|
+
# Defaults to +:db.part/user+.
|
63
|
+
# @return [String]
|
64
|
+
#
|
65
|
+
# @!attribute [r] attributes
|
66
|
+
# @return [Array] Definitions of each of the entity's attributes (name, type, options).
|
67
|
+
module ClassMethods
|
68
|
+
def partition
|
69
|
+
@partition
|
70
|
+
end
|
71
|
+
|
72
|
+
def partition=(partition)
|
73
|
+
self.partition = partition.to_sym
|
74
|
+
end
|
75
|
+
|
76
|
+
def attributes
|
77
|
+
@attributes
|
78
|
+
end
|
79
|
+
|
80
|
+
# Add an attribute to a {Diametric::Entity}.
|
81
|
+
#
|
82
|
+
# Valid options are:
|
83
|
+
#
|
84
|
+
# * +:index+: The only valid value is +true+. This causes the
|
85
|
+
# attribute to be indexed for easier lookup.
|
86
|
+
# * +:unique+: Valid values are +:value+ or +:identity.
|
87
|
+
# * +:value+ causes the attribute value to be unique to the
|
88
|
+
# entity and attempts to insert a duplicate value will fail.
|
89
|
+
# * +:identity+ causes the attribute value to be unique to
|
90
|
+
# the entity. Attempts to insert a duplicate value with a
|
91
|
+
# temporary entity id will result in an "upsert," causing the
|
92
|
+
# temporary entity's attributes to be merged with those for
|
93
|
+
# the current entity in Datomic.
|
94
|
+
# * +:cardinality+: Specifies whether an attribute associates
|
95
|
+
# a single value or a set of values with an entity. The
|
96
|
+
# values allowed are:
|
97
|
+
# * +:one+ - the attribute is single valued, it associates a
|
98
|
+
# single value with an entity.
|
99
|
+
# * +:many+ - the attribute is mutli valued, it associates a
|
100
|
+
# set of values with an entity.
|
101
|
+
# To be honest, I have no idea how this will work in Ruby. Try +:many+ at your own risk.
|
102
|
+
# +:one+ is the default.
|
103
|
+
# * +:doc+: A string used in Datomic to document the attribute.
|
104
|
+
# * +:fulltext+: The only valid value is +true+. Indicates that a
|
105
|
+
# fulltext search index should be generated for the attribute.
|
106
|
+
#
|
107
|
+
# @example Add an indexed name attribute.
|
108
|
+
# attribute :name, String, :index => true
|
109
|
+
#
|
110
|
+
# @param name [String] The attribute's name.
|
111
|
+
# @param value_type [Class] The attribute's type.
|
112
|
+
# Must exist in {Diametric::Entity::VALUE_TYPES}.
|
113
|
+
# @param opts [Hash] Options to pass to Datomic.
|
114
|
+
#
|
115
|
+
# @return void
|
116
|
+
def attribute(name, value_type, opts = {})
|
117
|
+
@attributes << [name, value_type, opts]
|
118
|
+
define_attribute_method name
|
119
|
+
define_method(name) do
|
120
|
+
instance_variable_get("@#{name}")
|
121
|
+
end
|
122
|
+
define_method("#{name}=") do |value|
|
123
|
+
send("#{name}_will_change!") unless value == instance_variable_get("@#{name}")
|
124
|
+
instance_variable_set("@#{name}", value)
|
125
|
+
end
|
126
|
+
end
|
127
|
+
|
128
|
+
# @return [Array<Symbol>] Names of the entity's attributes.
|
129
|
+
def attribute_names
|
130
|
+
@attributes.map { |name, _, _| name }
|
131
|
+
end
|
132
|
+
|
133
|
+
# Generates a Datomic schema for a model's attributes.
|
134
|
+
#
|
135
|
+
# @return [Array] A Datomic schema, as Ruby data that can be
|
136
|
+
# converted to EDN.
|
137
|
+
def schema
|
138
|
+
defaults = {
|
139
|
+
:"db/id" => tempid(:"db.part/db"),
|
140
|
+
:"db/cardinality" => :"db.cardinality/one",
|
141
|
+
:"db.install/_attribute" => :"db.part/db"
|
142
|
+
}
|
143
|
+
|
144
|
+
@attributes.reduce([]) do |schema, (attribute, value_type, opts)|
|
145
|
+
opts = opts.dup
|
146
|
+
unless opts.empty?
|
147
|
+
opts[:cardinality] = namespace("db.cardinality", opts[:cardinality]) if opts[:cardinality]
|
148
|
+
opts[:unique] = namespace("db.unique", opts[:unique]) if opts[:unique]
|
149
|
+
opts = opts.map { |k, v|
|
150
|
+
k = namespace("db", k)
|
151
|
+
[k, v]
|
152
|
+
}
|
153
|
+
opts = Hash[*opts.flatten]
|
154
|
+
end
|
155
|
+
|
156
|
+
schema << defaults.merge({
|
157
|
+
:"db/ident" => namespace(prefix, attribute),
|
158
|
+
:"db/valueType" => value_type(value_type),
|
159
|
+
}).merge(opts)
|
160
|
+
end
|
161
|
+
end
|
162
|
+
|
163
|
+
# Given a set of Ruby data returned from a Datomic query, this
|
164
|
+
# can re-hydrate that data into a model instance.
|
165
|
+
#
|
166
|
+
# @return [Entity]
|
167
|
+
def from_query(query_results)
|
168
|
+
dbid = query_results.shift
|
169
|
+
widget = self.new(Hash[*(@attributes.map { |attribute, _, _| attribute }.zip(query_results).flatten)])
|
170
|
+
widget.dbid = dbid
|
171
|
+
widget
|
172
|
+
end
|
173
|
+
|
174
|
+
# Returns the prefix for this model used in Datomic.
|
175
|
+
#
|
176
|
+
# @example
|
177
|
+
# Mouse.prefix #=> "mouse"
|
178
|
+
# FireHouse.prefix #=> "fire_house"
|
179
|
+
# Person::User.prefix #=> "person.user"
|
180
|
+
#
|
181
|
+
# @return [String]
|
182
|
+
def prefix
|
183
|
+
self.to_s.underscore.sub('/', '.')
|
184
|
+
end
|
185
|
+
|
186
|
+
# Create a temporary id placeholder.
|
187
|
+
#
|
188
|
+
# @param e [*#to_edn] Elements to put in the placeholder. Should
|
189
|
+
# be either partition or partition and a negative number to be
|
190
|
+
# used as a reference.
|
191
|
+
#
|
192
|
+
# @return [EDN::Type::Unknown] Temporary id placeholder.
|
193
|
+
def tempid(*e)
|
194
|
+
EDN.tagged_element('db/id', e)
|
195
|
+
end
|
196
|
+
|
197
|
+
# Namespace a attribute for Datomic.
|
198
|
+
#
|
199
|
+
# @param ns [#to_s] Namespace.
|
200
|
+
# @param attribute [#to_s] Attribute.
|
201
|
+
#
|
202
|
+
# @return [Symbol] Namespaced attribute.
|
203
|
+
def namespace(ns, attribute)
|
204
|
+
[ns.to_s, attribute.to_s].join("/").to_sym
|
205
|
+
end
|
206
|
+
|
207
|
+
private
|
208
|
+
|
209
|
+
def value_type(vt)
|
210
|
+
if vt.is_a?(Class)
|
211
|
+
vt = VALUE_TYPES[vt]
|
212
|
+
end
|
213
|
+
namespace("db.type", vt)
|
214
|
+
end
|
215
|
+
end
|
216
|
+
|
217
|
+
def dbid
|
218
|
+
@dbid
|
219
|
+
end
|
220
|
+
|
221
|
+
def dbid=(dbid)
|
222
|
+
@dbid = dbid
|
223
|
+
end
|
224
|
+
|
225
|
+
alias :id :dbid
|
226
|
+
alias :"id=" :"dbid="
|
227
|
+
|
228
|
+
# Create a new {Diametric::Entity}.
|
229
|
+
#
|
230
|
+
# @param params [Hash] A hash of attributes and values to
|
231
|
+
# initialize the entity with.
|
232
|
+
def initialize(params = {})
|
233
|
+
params.each do |k, v|
|
234
|
+
self.send("#{k}=", v)
|
235
|
+
end
|
236
|
+
end
|
237
|
+
|
238
|
+
# @return [Integer] A reference unique to this entity instance
|
239
|
+
# used in constructing temporary ids for transactions.
|
240
|
+
def temp_ref
|
241
|
+
@temp_ref ||= Diametric::Entity.next_temp_ref
|
242
|
+
end
|
243
|
+
|
244
|
+
# Creates data for a Datomic transaction.
|
245
|
+
#
|
246
|
+
# @param attributes [*Symbol] Attributes to save in the
|
247
|
+
# transaction. If no attributes are given, any changed
|
248
|
+
# attributes will be saved.
|
249
|
+
#
|
250
|
+
# @return [Array] Datomic transaction data.
|
251
|
+
def tx_data(*attributes)
|
252
|
+
tx = {:"db/id" => dbid || tempid}
|
253
|
+
attributes = self.changed_attributes.keys if attributes.empty?
|
254
|
+
attributes.reduce(tx) do |t, attribute|
|
255
|
+
t[self.class.namespace(self.class.prefix, attribute)] = self.send(attribute)
|
256
|
+
t
|
257
|
+
end
|
258
|
+
[tx]
|
259
|
+
end
|
260
|
+
|
261
|
+
# @return [Array<Symbol>] Names of the entity's attributes.
|
262
|
+
def attribute_names
|
263
|
+
self.class.attribute_names
|
264
|
+
end
|
265
|
+
|
266
|
+
# @return [EDN::Type::Unknown] A temporary id placeholder for
|
267
|
+
# use in transactions.
|
268
|
+
def tempid
|
269
|
+
self.class.tempid(self.class.partition, temp_ref)
|
270
|
+
end
|
271
|
+
|
272
|
+
# Checks to see if the two objects are the same entity in Datomic.
|
273
|
+
#
|
274
|
+
# @return [Boolean]
|
275
|
+
def ==(other)
|
276
|
+
return false if self.dbid.nil?
|
277
|
+
return false unless other.respond_to?(:dbid)
|
278
|
+
return false unless self.dbid == other.dbid
|
279
|
+
true
|
280
|
+
end
|
281
|
+
|
282
|
+
# Checks to see if the two objects are of the same type, are the same
|
283
|
+
# entity, and have the same attribute values.
|
284
|
+
#
|
285
|
+
# @return [Boolean]
|
286
|
+
def eql?(other)
|
287
|
+
return false unless self == other
|
288
|
+
return false unless self.class == other.class
|
289
|
+
|
290
|
+
attribute_names.each do |attr|
|
291
|
+
return false unless self.send(attr) == other.send(attr)
|
292
|
+
end
|
293
|
+
|
294
|
+
true
|
295
|
+
end
|
296
|
+
|
297
|
+
# Methods for ActiveModel compliance.
|
298
|
+
|
299
|
+
# For ActiveModel compliance.
|
300
|
+
# @return [self]
|
301
|
+
def to_model
|
302
|
+
self
|
303
|
+
end
|
304
|
+
|
305
|
+
# Key for use in REST URL's, etc.
|
306
|
+
# @return [Integer, nil]
|
307
|
+
def to_key
|
308
|
+
persisted? ? [dbid] : nil
|
309
|
+
end
|
310
|
+
|
311
|
+
# @return [Boolean] Is the entity persisted?
|
312
|
+
def persisted?
|
313
|
+
!dbid.nil?
|
314
|
+
end
|
315
|
+
|
316
|
+
# @return [Boolean] Is this a new entity (that is, not persisted)?
|
317
|
+
def new_record?
|
318
|
+
!persisted?
|
319
|
+
end
|
320
|
+
|
321
|
+
# @return [false] Is this entity destroyed? (Always false.) For
|
322
|
+
# ActiveModel compliance.
|
323
|
+
def destroyed?
|
324
|
+
false
|
325
|
+
end
|
326
|
+
|
327
|
+
# @return [Boolean] Is this entity valid? By default, this is
|
328
|
+
# always true.
|
329
|
+
def valid?
|
330
|
+
errors.empty?
|
331
|
+
end
|
332
|
+
|
333
|
+
# @return [ActiveModel::Errors] Errors on this entity. By default,
|
334
|
+
# this is empty.
|
335
|
+
def errors
|
336
|
+
@errors ||= ActiveModel::Errors.new(self)
|
337
|
+
end
|
338
|
+
end
|
339
|
+
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
module Diametric
|
2
|
+
module Persistence
|
3
|
+
module Common
|
4
|
+
def self.included(base)
|
5
|
+
base.send(:extend, ClassMethods)
|
6
|
+
end
|
7
|
+
|
8
|
+
module ClassMethods
|
9
|
+
def create_schema
|
10
|
+
transact(schema)
|
11
|
+
end
|
12
|
+
|
13
|
+
def first(conditions = {})
|
14
|
+
where(conditions).first
|
15
|
+
end
|
16
|
+
|
17
|
+
def where(conditions = {})
|
18
|
+
query = Diametric::Query.new(self)
|
19
|
+
query.where(conditions)
|
20
|
+
end
|
21
|
+
|
22
|
+
def filter(*filter)
|
23
|
+
query = Diametric::Query.new(self)
|
24
|
+
query.filter(*filter)
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
@@ -0,0 +1,107 @@
|
|
1
|
+
unless defined?(RUBY_ENGINE) && RUBY_ENGINE == "jruby"
|
2
|
+
raise "This module requires the use of JRuby."
|
3
|
+
end
|
4
|
+
|
5
|
+
require 'diametric'
|
6
|
+
require 'diametric/persistence/common'
|
7
|
+
|
8
|
+
require 'java'
|
9
|
+
require 'lock_jar'
|
10
|
+
lockfile = File.expand_path( "../../../../Jarfile.lock", __FILE__ )
|
11
|
+
# Loads the classpath with Jars from the lockfile
|
12
|
+
LockJar.load(lockfile)
|
13
|
+
|
14
|
+
require 'jrclj'
|
15
|
+
java_import "clojure.lang.Keyword"
|
16
|
+
|
17
|
+
module Diametric
|
18
|
+
module Persistence
|
19
|
+
module Peer
|
20
|
+
include_package "datomic"
|
21
|
+
|
22
|
+
@connection = nil
|
23
|
+
@persisted_classes = Set.new
|
24
|
+
|
25
|
+
def self.included(base)
|
26
|
+
base.send(:include, Diametric::Persistence::Common)
|
27
|
+
base.send(:extend, ClassMethods)
|
28
|
+
@persisted_classes.add(base)
|
29
|
+
end
|
30
|
+
|
31
|
+
def self.connection
|
32
|
+
@connection
|
33
|
+
end
|
34
|
+
|
35
|
+
def self.create_schemas
|
36
|
+
@persisted_classes.each do |klass|
|
37
|
+
klass.create_schema
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
module ClassMethods
|
42
|
+
include_package "datomic"
|
43
|
+
|
44
|
+
def connect(uri)
|
45
|
+
Java::Datomic::Peer.create_database(uri)
|
46
|
+
@connection = Java::Datomic::Peer.connect(uri)
|
47
|
+
end
|
48
|
+
|
49
|
+
def disconnect
|
50
|
+
@connection = nil
|
51
|
+
end
|
52
|
+
|
53
|
+
def connection
|
54
|
+
@connection || Diametric::Persistence::Peer.connection
|
55
|
+
end
|
56
|
+
|
57
|
+
def transact(data)
|
58
|
+
data = clj.edn_convert(data)
|
59
|
+
res = connection.transact(data)
|
60
|
+
res.get
|
61
|
+
end
|
62
|
+
|
63
|
+
def get(dbid)
|
64
|
+
entity_map = connection.db.entity(dbid)
|
65
|
+
attrs = entity_map.key_set.map { |attr_keyword|
|
66
|
+
attr = attr_keyword.to_s.gsub(%r"^:\w+/", '')
|
67
|
+
value = entity_map.get(attr_keyword)
|
68
|
+
[attr, value]
|
69
|
+
}
|
70
|
+
|
71
|
+
entity = self.new(Hash[*attrs.flatten])
|
72
|
+
entity.dbid = dbid
|
73
|
+
|
74
|
+
entity
|
75
|
+
end
|
76
|
+
|
77
|
+
def q(query, args)
|
78
|
+
Java::Datomic::Peer.q(clj.edn_convert(query), connection.db, *args)
|
79
|
+
end
|
80
|
+
|
81
|
+
def clj
|
82
|
+
@clj ||= JRClj.new
|
83
|
+
end
|
84
|
+
end
|
85
|
+
|
86
|
+
extend ClassMethods
|
87
|
+
|
88
|
+
def save
|
89
|
+
return false unless valid?
|
90
|
+
return true unless changed?
|
91
|
+
|
92
|
+
res = self.class.transact(tx_data)
|
93
|
+
if dbid.nil?
|
94
|
+
self.dbid = Java::Datomic::Peer.resolve_tempid(
|
95
|
+
res[:"db-after".to_clj],
|
96
|
+
res[:tempids.to_clj],
|
97
|
+
self.class.clj.edn_convert(tempid))
|
98
|
+
end
|
99
|
+
|
100
|
+
@previously_changed = changes
|
101
|
+
@changed_attributes.clear
|
102
|
+
|
103
|
+
res
|
104
|
+
end
|
105
|
+
end
|
106
|
+
end
|
107
|
+
end
|