terrestrial 0.3.0 → 0.5.0
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.
- checksums.yaml +5 -5
- data/.ruby-version +1 -1
- data/Gemfile.lock +44 -53
- data/README.md +3 -6
- data/bin/test +1 -1
- data/features/env.rb +12 -2
- data/features/example.feature +23 -26
- data/lib/terrestrial.rb +31 -0
- data/lib/terrestrial/adapters/abstract_adapter.rb +6 -0
- data/lib/terrestrial/adapters/memory_adapter.rb +82 -6
- data/lib/terrestrial/adapters/sequel_postgres_adapter.rb +191 -0
- data/lib/terrestrial/configurations/conventional_association_configuration.rb +65 -35
- data/lib/terrestrial/configurations/conventional_configuration.rb +280 -124
- data/lib/terrestrial/configurations/mapping_config_options_proxy.rb +97 -0
- data/lib/terrestrial/deleted_record.rb +12 -8
- data/lib/terrestrial/dirty_map.rb +17 -9
- data/lib/terrestrial/functional_pipeline.rb +64 -0
- data/lib/terrestrial/inspection_string.rb +6 -1
- data/lib/terrestrial/lazy_object_proxy.rb +1 -0
- data/lib/terrestrial/many_to_many_association.rb +34 -20
- data/lib/terrestrial/many_to_one_association.rb +11 -3
- data/lib/terrestrial/one_to_many_association.rb +9 -0
- data/lib/terrestrial/public_conveniencies.rb +65 -82
- data/lib/terrestrial/record.rb +106 -0
- data/lib/terrestrial/relation_mapping.rb +43 -12
- data/lib/terrestrial/relational_store.rb +33 -11
- data/lib/terrestrial/upsert_record.rb +54 -0
- data/lib/terrestrial/version.rb +1 -1
- data/spec/automatic_timestamps_spec.rb +339 -0
- data/spec/changes_api_spec.rb +81 -0
- data/spec/config_override_spec.rb +28 -19
- data/spec/custom_serializers_spec.rb +3 -2
- data/spec/database_default_fields_spec.rb +213 -0
- data/spec/database_generated_id_spec.rb +291 -0
- data/spec/database_owned_fields_and_timestamps_spec.rb +200 -0
- data/spec/deletion_spec.rb +1 -1
- data/spec/error_handling/factory_error_handling_spec.rb +1 -4
- data/spec/error_handling/serialization_error_spec.rb +1 -4
- data/spec/error_handling/upsert_error_spec.rb +7 -11
- data/spec/graph_persistence_spec.rb +52 -18
- data/spec/ordered_association_spec.rb +10 -12
- data/spec/predefined_queries_spec.rb +14 -12
- data/spec/readme_examples_spec.rb +1 -1
- data/spec/sequel_query_efficiency_spec.rb +19 -16
- data/spec/spec_helper.rb +6 -1
- data/spec/support/blog_schema.rb +7 -3
- data/spec/support/object_graph_setup.rb +30 -39
- data/spec/support/object_store_setup.rb +16 -196
- data/spec/support/seed_data_setup.rb +15 -149
- data/spec/support/seed_records.rb +141 -0
- data/spec/support/sequel_test_support.rb +46 -13
- data/spec/terrestrial/abstract_record_spec.rb +138 -106
- data/spec/terrestrial/adapters/sequel_postgres_adapter_spec.rb +138 -0
- data/spec/terrestrial/deleted_record_spec.rb +0 -27
- data/spec/terrestrial/dirty_map_spec.rb +52 -77
- data/spec/terrestrial/functional_pipeline_spec.rb +153 -0
- data/spec/terrestrial/inspection_string_spec.rb +61 -0
- data/spec/terrestrial/upsert_record_spec.rb +29 -0
- data/terrestrial.gemspec +7 -8
- metadata +43 -40
- data/MissingFeatures.md +0 -64
- data/lib/terrestrial/abstract_record.rb +0 -99
- data/lib/terrestrial/association_loaders.rb +0 -52
- data/lib/terrestrial/upserted_record.rb +0 -15
- data/spec/terrestrial/public_conveniencies_spec.rb +0 -63
- data/spec/terrestrial/upserted_record_spec.rb +0 -59
@@ -0,0 +1,191 @@
|
|
1
|
+
require "forwardable"
|
2
|
+
require "terrestrial/adapters/abstract_adapter"
|
3
|
+
|
4
|
+
module Terrestrial
|
5
|
+
module WrapDelegate
|
6
|
+
def wrap_delegators(target_name, method_names)
|
7
|
+
method_names.each do |method_name|
|
8
|
+
define_method(method_name) do |*args, &block|
|
9
|
+
self.class.new(
|
10
|
+
send(target_name).public_send(method_name, *args, &block)
|
11
|
+
)
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
module SequelDatasetComparisonLiteralAppendPatch
|
18
|
+
def ===(other)
|
19
|
+
other.is_a?(Adapters::SequelPostgresAdapter::Dataset) or
|
20
|
+
super
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
Sequel::Dataset.extend(SequelDatasetComparisonLiteralAppendPatch)
|
25
|
+
|
26
|
+
module Adapters
|
27
|
+
class SequelPostgresAdapter
|
28
|
+
extend Forwardable
|
29
|
+
include Adapters::AbstractAdapter
|
30
|
+
|
31
|
+
def initialize(database)
|
32
|
+
@database = database
|
33
|
+
end
|
34
|
+
|
35
|
+
attr_reader :database
|
36
|
+
private :database
|
37
|
+
|
38
|
+
def_delegators :database, *[
|
39
|
+
:transaction,
|
40
|
+
:tables,
|
41
|
+
]
|
42
|
+
|
43
|
+
def [](table_name)
|
44
|
+
Dataset.new(database[table_name])
|
45
|
+
end
|
46
|
+
|
47
|
+
def upsert(record)
|
48
|
+
row = perform_upsert_returning_row(record)
|
49
|
+
record.on_upsert(row)
|
50
|
+
nil
|
51
|
+
rescue Object => e
|
52
|
+
raise UpsertError.new(record.namespace, record.to_h, e)
|
53
|
+
end
|
54
|
+
|
55
|
+
def delete(record)
|
56
|
+
database[record.namespace].where(record.identity).delete
|
57
|
+
end
|
58
|
+
|
59
|
+
def changes_sql(record)
|
60
|
+
generate_upsert_sql(record)
|
61
|
+
rescue Object => e
|
62
|
+
raise UpsertError.new(record.namespace, record.to_h, e)
|
63
|
+
end
|
64
|
+
|
65
|
+
def conflict_fields(table_name)
|
66
|
+
primary_key(table_name)
|
67
|
+
end
|
68
|
+
|
69
|
+
def primary_key(table_name)
|
70
|
+
[database.primary_key(table_name)]
|
71
|
+
.compact
|
72
|
+
.map(&:to_sym)
|
73
|
+
end
|
74
|
+
|
75
|
+
def unique_indexes(table_name)
|
76
|
+
database.indexes(table_name).map { |_name, data|
|
77
|
+
data.fetch(:columns)
|
78
|
+
}
|
79
|
+
end
|
80
|
+
|
81
|
+
def relations
|
82
|
+
database.tables - [:schema_migrations]
|
83
|
+
end
|
84
|
+
|
85
|
+
def relation_fields(relation_name)
|
86
|
+
database[relation_name].columns
|
87
|
+
end
|
88
|
+
|
89
|
+
def schema(relation_name)
|
90
|
+
database.schema(relation_name)
|
91
|
+
end
|
92
|
+
|
93
|
+
private
|
94
|
+
|
95
|
+
def perform_upsert_returning_row(record)
|
96
|
+
sql = generate_upsert_sql(record)
|
97
|
+
result = database[sql]
|
98
|
+
.to_a
|
99
|
+
.fetch(0) { {} }
|
100
|
+
end
|
101
|
+
|
102
|
+
def generate_upsert_sql(record)
|
103
|
+
table_name = record.namespace
|
104
|
+
update_attributes = record.updatable? && record.updatable_attributes
|
105
|
+
|
106
|
+
primary_key_fields = primary_key(table_name)
|
107
|
+
|
108
|
+
missing_not_null_fields = database.schema(table_name)
|
109
|
+
.reject { |field_name, _| record.attributes.keys.include?(field_name) }
|
110
|
+
.select { |_field_name, properties|
|
111
|
+
allow_null = properties.fetch(:allow_null, true)
|
112
|
+
not_null = !allow_null
|
113
|
+
default = properties.fetch(:default, nil)
|
114
|
+
no_default = !default
|
115
|
+
|
116
|
+
not_null && no_default
|
117
|
+
}
|
118
|
+
.map(&:first)
|
119
|
+
.reject { |field_name| record.identity_fields.include?(field_name) }
|
120
|
+
|
121
|
+
missing_not_null_attrs = missing_not_null_fields
|
122
|
+
.map { |field_name| [field_name, database[table_name].select(field_name).where(record.identity)] }
|
123
|
+
.to_h
|
124
|
+
|
125
|
+
# TODO: investigate if failing to find a private key results in extra schema queries
|
126
|
+
if primary_key_fields.any?
|
127
|
+
if record.id?
|
128
|
+
conflict_fields = primary_key_fields
|
129
|
+
else
|
130
|
+
return database[table_name]
|
131
|
+
.returning(Sequel.lit("*"))
|
132
|
+
.insert_sql(record.insertable.merge(missing_not_null_attrs))
|
133
|
+
end
|
134
|
+
else
|
135
|
+
u_idxs = unique_indexes(table_name)
|
136
|
+
if u_idxs.any?
|
137
|
+
conflict_fields = u_idxs.first
|
138
|
+
end
|
139
|
+
end
|
140
|
+
|
141
|
+
upsert_args = { update: update_attributes }
|
142
|
+
|
143
|
+
if conflict_fields && conflict_fields.any?
|
144
|
+
upsert_args.merge!(target: conflict_fields)
|
145
|
+
end
|
146
|
+
|
147
|
+
# TODO: Use specific field list instead of Sequel.lit("*")
|
148
|
+
database[table_name]
|
149
|
+
.insert_conflict(**upsert_args)
|
150
|
+
.returning(Sequel.lit("*"))
|
151
|
+
.insert_sql(record.insertable.merge(missing_not_null_attrs))
|
152
|
+
end
|
153
|
+
|
154
|
+
class Dataset
|
155
|
+
extend Forwardable
|
156
|
+
extend WrapDelegate
|
157
|
+
include Enumerable
|
158
|
+
|
159
|
+
def initialize(dataset)
|
160
|
+
@dataset = dataset
|
161
|
+
end
|
162
|
+
|
163
|
+
attr_reader :dataset
|
164
|
+
private :dataset
|
165
|
+
|
166
|
+
wrap_delegators :dataset, [
|
167
|
+
:select,
|
168
|
+
:where,
|
169
|
+
:clone,
|
170
|
+
:order,
|
171
|
+
]
|
172
|
+
|
173
|
+
def_delegators :dataset, *[
|
174
|
+
:empty?,
|
175
|
+
:delete,
|
176
|
+
:opts,
|
177
|
+
:sql,
|
178
|
+
:reverse,
|
179
|
+
]
|
180
|
+
|
181
|
+
def cache_sql?
|
182
|
+
false
|
183
|
+
end
|
184
|
+
|
185
|
+
def each(&block)
|
186
|
+
dataset.each(&block)
|
187
|
+
end
|
188
|
+
end
|
189
|
+
end
|
190
|
+
end
|
191
|
+
end
|
@@ -10,22 +10,22 @@ module Terrestrial
|
|
10
10
|
require "terrestrial/lazy_object_proxy"
|
11
11
|
|
12
12
|
class ConventionalAssociationConfiguration
|
13
|
-
def initialize(mapping_name, mappings
|
14
|
-
@
|
15
|
-
@mappings = mappings
|
16
|
-
@local_mapping = mappings.fetch(local_mapping_name)
|
13
|
+
def initialize(inflector, datastore, mapping_name, mappings)
|
14
|
+
@inflector = inflector
|
17
15
|
@datastore = datastore
|
16
|
+
@target_mapping = mappings.fetch(mapping_name)
|
17
|
+
@mappings = mappings
|
18
18
|
end
|
19
19
|
|
20
|
-
attr_reader :
|
21
|
-
private :
|
20
|
+
attr_reader :inflector, :datastore, :target_mapping, :mappings
|
21
|
+
private :inflector, :datastore, :target_mapping, :mappings
|
22
22
|
|
23
23
|
DEFAULT = Module.new
|
24
24
|
|
25
25
|
def has_many(association_name, key: DEFAULT, foreign_key: DEFAULT, mapping_name: DEFAULT, order_fields: DEFAULT, order_direction: DEFAULT)
|
26
26
|
defaults = {
|
27
27
|
mapping_name: association_name,
|
28
|
-
foreign_key: [
|
28
|
+
foreign_key: [singular_name, "_id"].join.to_sym,
|
29
29
|
key: :id,
|
30
30
|
order_fields: [],
|
31
31
|
order_direction: "ASC",
|
@@ -45,17 +45,14 @@ module Terrestrial
|
|
45
45
|
associated_mapping_name = config.fetch(:mapping_name)
|
46
46
|
associated_mapping = mappings.fetch(associated_mapping_name)
|
47
47
|
|
48
|
-
|
49
|
-
association_name,
|
50
|
-
has_many_mapper(**config)
|
51
|
-
)
|
48
|
+
has_many_mapper(**config)
|
52
49
|
end
|
53
50
|
|
54
51
|
def belongs_to(association_name, key: DEFAULT, foreign_key: DEFAULT, mapping_name: DEFAULT)
|
55
52
|
defaults = {
|
56
53
|
key: :id,
|
57
54
|
foreign_key: [association_name, "_id"].join.to_sym,
|
58
|
-
mapping_name:
|
55
|
+
mapping_name: pluralize(association_name).to_sym,
|
59
56
|
}
|
60
57
|
|
61
58
|
specified = {
|
@@ -71,19 +68,17 @@ module Terrestrial
|
|
71
68
|
associated_mapping_name = config.fetch(:mapping_name)
|
72
69
|
associated_mapping = mappings.fetch(associated_mapping_name)
|
73
70
|
|
74
|
-
|
75
|
-
association_name,
|
76
|
-
belongs_to_mapper(**config)
|
77
|
-
)
|
71
|
+
belongs_to_mapper(**config)
|
78
72
|
end
|
79
73
|
|
80
|
-
def has_many_through(association_name, key: DEFAULT, foreign_key: DEFAULT, mapping_name: DEFAULT,
|
74
|
+
def has_many_through(association_name, key: DEFAULT, foreign_key: DEFAULT, mapping_name: DEFAULT, through_table_name: DEFAULT, association_key: DEFAULT, association_foreign_key: DEFAULT, order_fields: DEFAULT, order_direction: DEFAULT)
|
75
|
+
# TODO: join_dataset as mutually exclusive option with join_table_name
|
81
76
|
defaults = {
|
82
77
|
mapping_name: association_name,
|
83
78
|
key: :id,
|
84
79
|
association_key: :id,
|
85
|
-
foreign_key: [
|
86
|
-
association_foreign_key: [
|
80
|
+
foreign_key: [singular_name, "_id"].join.to_sym,
|
81
|
+
association_foreign_key: [singularize(association_name), "_id"].join.to_sym,
|
87
82
|
order_fields: [],
|
88
83
|
order_direction: "ASC",
|
89
84
|
}
|
@@ -101,26 +96,30 @@ module Terrestrial
|
|
101
96
|
}
|
102
97
|
|
103
98
|
config = defaults.merge(specified)
|
99
|
+
|
104
100
|
associated_mapping = mappings.fetch(config.fetch(:mapping_name))
|
101
|
+
default_through_table_name = [associated_mapping.name, target_mapping.name].sort.join("_to_").to_sym
|
105
102
|
|
106
|
-
if
|
107
|
-
|
108
|
-
associated_mapping.name,
|
109
|
-
local_mapping.name,
|
110
|
-
].sort.join("_to_").to_sym
|
103
|
+
if through_table_name == DEFAULT
|
104
|
+
through_table_name = default_through_table_name
|
111
105
|
end
|
112
106
|
|
113
|
-
|
114
|
-
|
115
|
-
|
116
|
-
|
117
|
-
|
118
|
-
|
107
|
+
join_mapping = create_virtual_mapping(
|
108
|
+
default_mapping_name: default_through_table_name,
|
109
|
+
namespace: through_table_name,
|
110
|
+
primary_key: [config[:foreign_key], config[:association_foreign_key]],
|
111
|
+
)
|
112
|
+
|
113
|
+
mappings[join_mapping.name] = join_mapping
|
119
114
|
|
120
|
-
|
121
|
-
|
122
|
-
|
115
|
+
join_dataset = datastore[through_table_name.to_sym]
|
116
|
+
|
117
|
+
config = config.merge(
|
118
|
+
join_mapping_name: join_mapping.name,
|
119
|
+
join_dataset: join_dataset,
|
123
120
|
)
|
121
|
+
|
122
|
+
has_many_through_mapper(**config)
|
124
123
|
end
|
125
124
|
|
126
125
|
private
|
@@ -144,10 +143,11 @@ module Terrestrial
|
|
144
143
|
)
|
145
144
|
end
|
146
145
|
|
147
|
-
def has_many_through_mapper(mapping_name:, key:, foreign_key:, association_key:, association_foreign_key:,
|
146
|
+
def has_many_through_mapper(mapping_name:, key:, foreign_key:, association_key:, association_foreign_key:, join_mapping_name:, join_dataset:, order_fields:, order_direction:)
|
148
147
|
ManyToManyAssociation.new(
|
149
148
|
mapping_name: mapping_name,
|
150
|
-
join_mapping_name:
|
149
|
+
join_mapping_name: join_mapping_name,
|
150
|
+
join_dataset: join_dataset, # TODO: this dataset is not used
|
151
151
|
key: key,
|
152
152
|
foreign_key: foreign_key,
|
153
153
|
association_key: association_key,
|
@@ -157,6 +157,24 @@ module Terrestrial
|
|
157
157
|
)
|
158
158
|
end
|
159
159
|
|
160
|
+
def create_virtual_mapping(default_mapping_name:, namespace:, primary_key:)
|
161
|
+
mapping_name = "__generated_virtual_mapping_#{default_mapping_name}"
|
162
|
+
|
163
|
+
RelationMapping.new(
|
164
|
+
name: mapping_name,
|
165
|
+
namespace: namespace,
|
166
|
+
primary_key: primary_key,
|
167
|
+
factory: ->(*_) { },
|
168
|
+
serializer: :to_h.to_proc,
|
169
|
+
fields: [],
|
170
|
+
associations: [],
|
171
|
+
subsets: [],
|
172
|
+
database_owned_fields: [],
|
173
|
+
database_default_fields: [],
|
174
|
+
observers: [],
|
175
|
+
)
|
176
|
+
end
|
177
|
+
|
160
178
|
def single_object_proxy_factory
|
161
179
|
->(query:, loader:, preloaded_data:) {
|
162
180
|
LazyObjectProxy.new(
|
@@ -181,6 +199,18 @@ module Terrestrial
|
|
181
199
|
def query_order(fields, direction)
|
182
200
|
QueryOrder.new(fields: fields, direction: direction)
|
183
201
|
end
|
202
|
+
|
203
|
+
def singular_name
|
204
|
+
inflector.singularize(target_mapping.name)
|
205
|
+
end
|
206
|
+
|
207
|
+
def singularize(string)
|
208
|
+
inflector.singularize(string)
|
209
|
+
end
|
210
|
+
|
211
|
+
def pluralize(string)
|
212
|
+
inflector.pluralize(string)
|
213
|
+
end
|
184
214
|
end
|
185
215
|
end
|
186
216
|
end
|
@@ -1,113 +1,57 @@
|
|
1
|
+
require "fetchable"
|
2
|
+
|
3
|
+
require "terrestrial/configurations/mapping_config_options_proxy"
|
1
4
|
require "terrestrial/configurations/conventional_association_configuration"
|
2
5
|
require "terrestrial/relation_mapping"
|
3
6
|
require "terrestrial/subset_queries_proxy"
|
4
7
|
require "terrestrial/struct_factory"
|
8
|
+
require "sequel/model/inflections"
|
5
9
|
|
6
10
|
module Terrestrial
|
7
11
|
module Configurations
|
8
|
-
|
12
|
+
|
9
13
|
class Inflector
|
10
|
-
include
|
14
|
+
include Sequel::Inflections
|
15
|
+
|
16
|
+
def classify(string)
|
17
|
+
singularize(camelize(string))
|
18
|
+
end
|
19
|
+
|
20
|
+
public :singularize
|
21
|
+
public :pluralize
|
11
22
|
end
|
12
23
|
|
13
|
-
|
24
|
+
Default = Module.new
|
14
25
|
|
15
|
-
require "fetchable"
|
16
26
|
class ConventionalConfiguration
|
27
|
+
include Enumerable
|
17
28
|
include Fetchable
|
18
29
|
|
19
|
-
def initialize(datastore)
|
30
|
+
def initialize(datastore:, clock:, dirty_map:, identity_map:, inflector: Inflector.new)
|
20
31
|
@datastore = datastore
|
32
|
+
@inflector = inflector
|
33
|
+
@dirty_map = dirty_map
|
34
|
+
@identity_map = identity_map
|
35
|
+
@clock = clock
|
36
|
+
|
21
37
|
@overrides = {}
|
22
38
|
@subset_queries = {}
|
23
39
|
@associations_by_mapping = {}
|
24
40
|
end
|
25
41
|
|
26
|
-
attr_reader :datastore, :mappings
|
27
|
-
private :datastore, :mappings
|
28
|
-
|
29
|
-
def [](mapping_name)
|
30
|
-
mappings[mapping_name]
|
31
|
-
end
|
32
|
-
|
33
|
-
include Enumerable
|
34
|
-
def each(&block)
|
35
|
-
mappings.each(&block)
|
36
|
-
end
|
42
|
+
attr_reader :overrides, :clock, :identity_map, :dirty_map, :datastore, :mappings, :inflector
|
37
43
|
|
38
44
|
def setup_mapping(mapping_name, &block)
|
39
45
|
@associations_by_mapping[mapping_name] ||= []
|
46
|
+
@overrides[mapping_name] ||= {}
|
40
47
|
|
41
|
-
block.call(
|
42
|
-
|
43
|
-
|
44
|
-
method(:add_subset).to_proc.curry.call(mapping_name),
|
45
|
-
@associations_by_mapping.fetch(mapping_name),
|
46
|
-
)
|
47
|
-
) if block
|
48
|
-
|
49
|
-
# TODO: more madness in this silly config this, kill it with fire.
|
50
|
-
explicit_settings = @overrides[mapping_name] ||= {}
|
51
|
-
explicit_settings[:factory] ||= raise_if_not_found_factory(mapping_name)
|
48
|
+
block && block.call(
|
49
|
+
MappingConfigOptionsProxy.new(self, mapping_name)
|
50
|
+
)
|
52
51
|
|
53
52
|
self
|
54
53
|
end
|
55
54
|
|
56
|
-
private
|
57
|
-
|
58
|
-
class RelationConfigOptionsProxy
|
59
|
-
def initialize(config_override, subset_adder, association_register)
|
60
|
-
@config_override = config_override
|
61
|
-
@subset_adder = subset_adder
|
62
|
-
@association_register = association_register
|
63
|
-
end
|
64
|
-
|
65
|
-
def relation_name(name)
|
66
|
-
@config_override.call(relation_name: name)
|
67
|
-
end
|
68
|
-
alias_method :table_name, :relation_name
|
69
|
-
|
70
|
-
def subset(subset_name, &block)
|
71
|
-
@subset_adder.call(subset_name, block)
|
72
|
-
end
|
73
|
-
|
74
|
-
def has_many(*args)
|
75
|
-
@association_register.push([:has_many, args])
|
76
|
-
end
|
77
|
-
|
78
|
-
def has_many_through(*args)
|
79
|
-
@association_register.push([:has_many_through, args])
|
80
|
-
end
|
81
|
-
|
82
|
-
def belongs_to(*args)
|
83
|
-
@association_register.push([:belongs_to, args])
|
84
|
-
end
|
85
|
-
|
86
|
-
def fields(field_names)
|
87
|
-
@config_override.call(fields: field_names)
|
88
|
-
end
|
89
|
-
|
90
|
-
def primary_key(field_names)
|
91
|
-
@config_override.call(primary_key: field_names)
|
92
|
-
end
|
93
|
-
|
94
|
-
def factory(callable)
|
95
|
-
@config_override.call(factory: callable)
|
96
|
-
end
|
97
|
-
|
98
|
-
def class(entity_class)
|
99
|
-
@config_override.call('class': entity_class)
|
100
|
-
end
|
101
|
-
|
102
|
-
def class_name(class_name)
|
103
|
-
@config_override.call(class_name: class_name)
|
104
|
-
end
|
105
|
-
|
106
|
-
def serializer(serializer_func)
|
107
|
-
@config_override.call(serializer: serializer_func)
|
108
|
-
end
|
109
|
-
end
|
110
|
-
|
111
55
|
def mappings
|
112
56
|
@mappings ||= generate_mappings
|
113
57
|
end
|
@@ -127,11 +71,18 @@ module Terrestrial
|
|
127
71
|
)
|
128
72
|
end
|
129
73
|
|
74
|
+
def add_association(mapping_name, type, options)
|
75
|
+
@associations_by_mapping.fetch(mapping_name).push([type, options])
|
76
|
+
end
|
77
|
+
|
78
|
+
private
|
79
|
+
|
130
80
|
def association_configurator(mappings, mapping_name)
|
131
81
|
ConventionalAssociationConfiguration.new(
|
82
|
+
inflector,
|
83
|
+
datastore,
|
132
84
|
mapping_name,
|
133
85
|
mappings,
|
134
|
-
datastore,
|
135
86
|
)
|
136
87
|
end
|
137
88
|
|
@@ -140,17 +91,13 @@ module Terrestrial
|
|
140
91
|
[mapping_name, {relation_name: mapping_name}.merge(consolidate_overrides(overrides))]
|
141
92
|
}
|
142
93
|
|
143
|
-
table_mappings = (tables - @overrides.keys).map { |table_name|
|
144
|
-
[table_name, overrides_for_table(table_name)]
|
145
|
-
}
|
146
|
-
|
147
94
|
Hash[
|
148
|
-
(
|
95
|
+
(custom_mappings).map { |(mapping_name, overrides)|
|
149
96
|
table_name = overrides.fetch(:relation_name) { raise no_table_error(mapping_name) }
|
150
97
|
|
151
98
|
[
|
152
99
|
mapping_name,
|
153
|
-
|
100
|
+
build_mapping(
|
154
101
|
**default_mapping_args(table_name, mapping_name).merge(overrides)
|
155
102
|
),
|
156
103
|
]
|
@@ -166,10 +113,16 @@ module Terrestrial
|
|
166
113
|
# as a dependency and then sends mutating messages to them.
|
167
114
|
# This mutation based approach was originally a spike but now just
|
168
115
|
# seems totally bananas!
|
169
|
-
@associations_by_mapping.each do |mapping_name,
|
170
|
-
|
171
|
-
association_configurator(mappings, mapping_name)
|
116
|
+
@associations_by_mapping.each do |mapping_name, association_data|
|
117
|
+
association_data.each do |(assoc_type, assoc_args)|
|
118
|
+
association = association_configurator(mappings, mapping_name)
|
172
119
|
.public_send(assoc_type, *assoc_args)
|
120
|
+
|
121
|
+
name = assoc_args.fetch(0)
|
122
|
+
mappings.fetch(mapping_name).add_association(name, association)
|
123
|
+
associated_mapping = mappings.fetch(association.mapping_name)
|
124
|
+
|
125
|
+
associated_mapping.register_foreign_key(association.outgoing_foreign_keys)
|
173
126
|
end
|
174
127
|
end
|
175
128
|
end
|
@@ -178,9 +131,17 @@ module Terrestrial
|
|
178
131
|
{
|
179
132
|
name: mapping_name,
|
180
133
|
relation_name: table_name,
|
181
|
-
fields:
|
134
|
+
fields: all_available_fields(table_name),
|
182
135
|
primary_key: get_primary_key(table_name),
|
183
|
-
|
136
|
+
use_database_id: false,
|
137
|
+
database_id_setter: nil,
|
138
|
+
database_owned_fields_setter_map: {},
|
139
|
+
database_default_fields_setter_map: {},
|
140
|
+
updated_at_field: nil,
|
141
|
+
updated_at_setter: nil,
|
142
|
+
created_at_field: nil,
|
143
|
+
created_at_setter: nil,
|
144
|
+
factory: ok_if_class_is_not_defined_factory(mapping_name),
|
184
145
|
serializer: hash_coercion_serializer,
|
185
146
|
associations: {},
|
186
147
|
subsets: subset_queries_proxy(@subset_queries.fetch(mapping_name, {})),
|
@@ -213,31 +174,70 @@ module Terrestrial
|
|
213
174
|
new_opts
|
214
175
|
end
|
215
176
|
|
216
|
-
def
|
217
|
-
datastore
|
177
|
+
def all_available_fields(relation_name)
|
178
|
+
datastore.relation_fields(relation_name)
|
218
179
|
end
|
219
180
|
|
220
181
|
def get_primary_key(table_name)
|
221
182
|
datastore.schema(table_name)
|
222
183
|
.select { |field_name, properties|
|
223
|
-
properties.fetch(:primary_key)
|
184
|
+
properties.fetch(:primary_key, false)
|
224
185
|
}
|
225
186
|
.map { |field_name, _| field_name }
|
226
187
|
end
|
227
188
|
|
189
|
+
# TODO: inconsisent naming
|
228
190
|
def tables
|
229
|
-
|
191
|
+
datastore.relations
|
230
192
|
end
|
231
193
|
|
232
194
|
def hash_coercion_serializer
|
233
|
-
|
195
|
+
HashCoercionSerializer.new
|
234
196
|
end
|
235
197
|
|
236
198
|
def subset_queries_proxy(subset_map)
|
237
199
|
SubsetQueriesProxy.new(subset_map)
|
238
200
|
end
|
239
201
|
|
240
|
-
def
|
202
|
+
def build_mapping(name:, relation_name:, primary_key:, use_database_id:, database_id_setter:, database_owned_fields_setter_map:, database_default_fields_setter_map:, updated_at_field:, updated_at_setter:, created_at_field:, created_at_setter:, factory:, serializer:, fields:, associations:, subsets:)
|
203
|
+
if use_database_id
|
204
|
+
database_id_setter ||= object_setter(primary_key.first)
|
205
|
+
end
|
206
|
+
if created_at_field
|
207
|
+
created_at_field = created_at_field == Default ? :created_at : created_at_field
|
208
|
+
created_at_setter ||= object_setter(created_at_field)
|
209
|
+
end
|
210
|
+
if updated_at_field
|
211
|
+
updated_at_field = updated_at_field == Default ? :updated_at : updated_at_field
|
212
|
+
updated_at_setter ||= object_setter(updated_at_field)
|
213
|
+
end
|
214
|
+
|
215
|
+
timestamp_observer = TimestampObserver.new(
|
216
|
+
clock,
|
217
|
+
dirty_map,
|
218
|
+
created_at_field,
|
219
|
+
created_at_setter,
|
220
|
+
updated_at_field,
|
221
|
+
updated_at_setter,
|
222
|
+
)
|
223
|
+
|
224
|
+
database_owned_field_observers = database_owned_fields_setter_map.map { |field, setter|
|
225
|
+
setter ||= ->(object, value) { object.send("#{field}=", value) }
|
226
|
+
ArbitraryDatabaseOwnedValueObserver.new(field, setter)
|
227
|
+
}
|
228
|
+
|
229
|
+
database_default_field_observers = database_default_fields_setter_map.map { |field, setter|
|
230
|
+
setter ||= ->(object, value) { object.send("#{field}=", value) }
|
231
|
+
ArbitraryDatabaseDefaultValueObserver.new(field, setter)
|
232
|
+
}
|
233
|
+
|
234
|
+
observers = [
|
235
|
+
use_database_id && DatabaseIDObserver.new(database_id_setter),
|
236
|
+
(created_at_field || updated_at_field) && timestamp_observer,
|
237
|
+
*database_owned_field_observers,
|
238
|
+
*database_default_field_observers,
|
239
|
+
].select(&:itself)
|
240
|
+
|
241
241
|
RelationMapping.new(
|
242
242
|
name: name,
|
243
243
|
namespace: relation_name,
|
@@ -245,39 +245,29 @@ module Terrestrial
|
|
245
245
|
factory: factory,
|
246
246
|
serializer: serializer,
|
247
247
|
fields: fields,
|
248
|
+
database_owned_fields: database_owned_fields_setter_map.keys,
|
249
|
+
database_default_fields: database_default_fields_setter_map.keys,
|
248
250
|
associations: associations,
|
249
251
|
subsets: subsets,
|
252
|
+
observers: observers,
|
250
253
|
)
|
251
254
|
end
|
252
255
|
|
253
|
-
|
254
|
-
|
255
|
-
@specified = specified
|
256
|
-
end
|
257
|
-
|
258
|
-
def message
|
259
|
-
"Could not find factory for #{@specified}"
|
260
|
-
end
|
256
|
+
def object_setter(field_name)
|
257
|
+
SetterMethodCaller.new(field_name)
|
261
258
|
end
|
262
259
|
|
263
|
-
|
264
|
-
|
265
|
-
@message = "Error defining custom mapping `#{mapping_name}`." +
|
266
|
-
" You must provide the `table_name` configuration option."
|
267
|
-
end
|
260
|
+
def simple_setter_method_caller(primary_key)
|
261
|
+
SetterMethodCaller.new(primary_key.first)
|
268
262
|
end
|
269
263
|
|
270
|
-
def
|
271
|
-
|
272
|
-
|
273
|
-
}
|
264
|
+
def class_with_same_name_as_mapping_factory(name)
|
265
|
+
target_class = string_to_class(name)
|
266
|
+
ClassFactory.new(target_class)
|
274
267
|
end
|
275
268
|
|
276
|
-
def
|
277
|
-
|
278
|
-
factory = class_to_factory(string_to_class(name)) rescue nil
|
279
|
-
factory && factory.call(attrs)
|
280
|
-
}
|
269
|
+
def ok_if_class_is_not_defined_factory(name)
|
270
|
+
LazyClassLookupFactory.new(class_name(name))
|
281
271
|
end
|
282
272
|
|
283
273
|
def class_to_factory(klass)
|
@@ -289,14 +279,180 @@ module Terrestrial
|
|
289
279
|
end
|
290
280
|
|
291
281
|
def string_to_class(string)
|
292
|
-
|
282
|
+
Object.const_get(class_name(string))
|
283
|
+
end
|
293
284
|
|
294
|
-
|
285
|
+
def class_name(name)
|
286
|
+
inflector.classify(name)
|
295
287
|
end
|
296
288
|
|
297
289
|
def no_table_error(table_name)
|
298
290
|
TableNameNotSpecifiedError.new(table_name)
|
299
291
|
end
|
292
|
+
|
293
|
+
class ClassFactory
|
294
|
+
def initialize(target_class)
|
295
|
+
@target_class = target_class
|
296
|
+
end
|
297
|
+
|
298
|
+
def call(attrs)
|
299
|
+
@target_class.new(attrs)
|
300
|
+
end
|
301
|
+
end
|
302
|
+
|
303
|
+
class LazyClassLookupFactory
|
304
|
+
def initialize(class_name)
|
305
|
+
@class_name = class_name
|
306
|
+
end
|
307
|
+
|
308
|
+
def call(attrs)
|
309
|
+
target_class && target_class.new(attrs)
|
310
|
+
end
|
311
|
+
|
312
|
+
private
|
313
|
+
|
314
|
+
def target_class
|
315
|
+
@target_class ||= Object.const_get(@class_name)
|
316
|
+
end
|
317
|
+
end
|
318
|
+
|
319
|
+
class HashCoercionSerializer
|
320
|
+
def call(object)
|
321
|
+
object.to_h
|
322
|
+
end
|
323
|
+
end
|
324
|
+
|
325
|
+
class TableNameNotSpecifiedError < StandardError
|
326
|
+
def initialize(mapping_name)
|
327
|
+
@message = "Error defining custom mapping `#{mapping_name}`." \
|
328
|
+
" You must provide the `table_name` configuration option."
|
329
|
+
end
|
330
|
+
end
|
331
|
+
|
332
|
+
class DatabaseIDObserver
|
333
|
+
def initialize(setter)
|
334
|
+
@setter = setter
|
335
|
+
end
|
336
|
+
|
337
|
+
attr_reader :setter
|
338
|
+
private :setter
|
339
|
+
|
340
|
+
def post_serialize(mapping, object, record)
|
341
|
+
add_database_id_container!(record)
|
342
|
+
end
|
343
|
+
|
344
|
+
def post_save(mapping, object, record, new_record)
|
345
|
+
if !record.id?
|
346
|
+
new_id = new_record.identity_values.first
|
347
|
+
record.identity_values.first.value = new_id
|
348
|
+
setter.call(object, new_id)
|
349
|
+
end
|
350
|
+
end
|
351
|
+
|
352
|
+
private
|
353
|
+
|
354
|
+
def add_database_id_container!(record)
|
355
|
+
if !record.id?
|
356
|
+
record.set_id(database_id_container)
|
357
|
+
end
|
358
|
+
end
|
359
|
+
|
360
|
+
def database_id_container
|
361
|
+
Terrestrial::DatabaseID.new
|
362
|
+
end
|
363
|
+
end
|
364
|
+
|
365
|
+
# TODO: It is very tempting to implement database generated IDs in terms of this
|
366
|
+
class ArbitraryDatabaseOwnedValueObserver
|
367
|
+
def initialize(field_name, setter)
|
368
|
+
@field_name = field_name
|
369
|
+
@setter = setter
|
370
|
+
end
|
371
|
+
|
372
|
+
attr_reader :field_name, :setter
|
373
|
+
private :field_name, :setter
|
374
|
+
|
375
|
+
def post_serialize(*_args)
|
376
|
+
end
|
377
|
+
|
378
|
+
def post_save(mapping, object, record, new_record)
|
379
|
+
setter.call(object, new_record.get(field_name))
|
380
|
+
end
|
381
|
+
end
|
382
|
+
|
383
|
+
class ArbitraryDatabaseDefaultValueObserver
|
384
|
+
def initialize(field_name, setter)
|
385
|
+
@field_name = field_name
|
386
|
+
@setter = setter
|
387
|
+
end
|
388
|
+
|
389
|
+
attr_reader :field_name, :setter
|
390
|
+
private :field_name, :setter
|
391
|
+
|
392
|
+
def post_serialize(*_args)
|
393
|
+
end
|
394
|
+
|
395
|
+
def post_save(mapping, object, record, new_record)
|
396
|
+
if value_changed?(new_record, record)
|
397
|
+
setter.call(object, new_record.get(field_name))
|
398
|
+
end
|
399
|
+
end
|
400
|
+
|
401
|
+
private
|
402
|
+
|
403
|
+
def value_changed?(new_record, old_record)
|
404
|
+
new_record.attributes[field_name] != old_record.attributes[field_name]
|
405
|
+
end
|
406
|
+
end
|
407
|
+
|
408
|
+
class TimestampObserver
|
409
|
+
def initialize(clock, dirty_map, created_at_field, created_at_setter, updated_at_field, updated_at_setter)
|
410
|
+
@clock = clock
|
411
|
+
@dirty_map = dirty_map
|
412
|
+
@created_at_field = created_at_field
|
413
|
+
@created_at_setter = created_at_setter
|
414
|
+
@updated_at_field = updated_at_field
|
415
|
+
@updated_at_setter = updated_at_setter
|
416
|
+
end
|
417
|
+
|
418
|
+
attr_reader :clock, :dirty_map, :created_at_field, :updated_at_field, :created_at_setter, :updated_at_setter
|
419
|
+
private :clock, :dirty_map, :created_at_field, :updated_at_field, :created_at_setter, :updated_at_setter
|
420
|
+
|
421
|
+
def post_serialize(mapping, object, record)
|
422
|
+
time = clock.now
|
423
|
+
|
424
|
+
if created_at_field && !record.get(created_at_field)
|
425
|
+
record.set(created_at_field, time)
|
426
|
+
end
|
427
|
+
|
428
|
+
if updated_at_field && dirty_map.dirty?(record)
|
429
|
+
record.set(updated_at_field, time)
|
430
|
+
end
|
431
|
+
end
|
432
|
+
|
433
|
+
def post_save(mapping, object, record, new_record)
|
434
|
+
if created_at_field && record.fetch(created_at_field, false)
|
435
|
+
time = record.fetch(created_at_field)
|
436
|
+
created_at_setter.call(object, time)
|
437
|
+
end
|
438
|
+
|
439
|
+
if updated_at_field
|
440
|
+
time = record.get(updated_at_field)
|
441
|
+
updated_at_setter.call(object, time)
|
442
|
+
end
|
443
|
+
end
|
444
|
+
end
|
445
|
+
|
446
|
+
class SetterMethodCaller
|
447
|
+
def initialize(field_name)
|
448
|
+
raise "hell no" unless field_name
|
449
|
+
@setter_method = "#{field_name}="
|
450
|
+
end
|
451
|
+
|
452
|
+
def call(object, value)
|
453
|
+
object.public_send(@setter_method, value)
|
454
|
+
end
|
455
|
+
end
|
300
456
|
end
|
301
457
|
end
|
302
458
|
end
|