terrestrial 0.3.0 → 0.5.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (66) hide show
  1. checksums.yaml +5 -5
  2. data/.ruby-version +1 -1
  3. data/Gemfile.lock +44 -53
  4. data/README.md +3 -6
  5. data/bin/test +1 -1
  6. data/features/env.rb +12 -2
  7. data/features/example.feature +23 -26
  8. data/lib/terrestrial.rb +31 -0
  9. data/lib/terrestrial/adapters/abstract_adapter.rb +6 -0
  10. data/lib/terrestrial/adapters/memory_adapter.rb +82 -6
  11. data/lib/terrestrial/adapters/sequel_postgres_adapter.rb +191 -0
  12. data/lib/terrestrial/configurations/conventional_association_configuration.rb +65 -35
  13. data/lib/terrestrial/configurations/conventional_configuration.rb +280 -124
  14. data/lib/terrestrial/configurations/mapping_config_options_proxy.rb +97 -0
  15. data/lib/terrestrial/deleted_record.rb +12 -8
  16. data/lib/terrestrial/dirty_map.rb +17 -9
  17. data/lib/terrestrial/functional_pipeline.rb +64 -0
  18. data/lib/terrestrial/inspection_string.rb +6 -1
  19. data/lib/terrestrial/lazy_object_proxy.rb +1 -0
  20. data/lib/terrestrial/many_to_many_association.rb +34 -20
  21. data/lib/terrestrial/many_to_one_association.rb +11 -3
  22. data/lib/terrestrial/one_to_many_association.rb +9 -0
  23. data/lib/terrestrial/public_conveniencies.rb +65 -82
  24. data/lib/terrestrial/record.rb +106 -0
  25. data/lib/terrestrial/relation_mapping.rb +43 -12
  26. data/lib/terrestrial/relational_store.rb +33 -11
  27. data/lib/terrestrial/upsert_record.rb +54 -0
  28. data/lib/terrestrial/version.rb +1 -1
  29. data/spec/automatic_timestamps_spec.rb +339 -0
  30. data/spec/changes_api_spec.rb +81 -0
  31. data/spec/config_override_spec.rb +28 -19
  32. data/spec/custom_serializers_spec.rb +3 -2
  33. data/spec/database_default_fields_spec.rb +213 -0
  34. data/spec/database_generated_id_spec.rb +291 -0
  35. data/spec/database_owned_fields_and_timestamps_spec.rb +200 -0
  36. data/spec/deletion_spec.rb +1 -1
  37. data/spec/error_handling/factory_error_handling_spec.rb +1 -4
  38. data/spec/error_handling/serialization_error_spec.rb +1 -4
  39. data/spec/error_handling/upsert_error_spec.rb +7 -11
  40. data/spec/graph_persistence_spec.rb +52 -18
  41. data/spec/ordered_association_spec.rb +10 -12
  42. data/spec/predefined_queries_spec.rb +14 -12
  43. data/spec/readme_examples_spec.rb +1 -1
  44. data/spec/sequel_query_efficiency_spec.rb +19 -16
  45. data/spec/spec_helper.rb +6 -1
  46. data/spec/support/blog_schema.rb +7 -3
  47. data/spec/support/object_graph_setup.rb +30 -39
  48. data/spec/support/object_store_setup.rb +16 -196
  49. data/spec/support/seed_data_setup.rb +15 -149
  50. data/spec/support/seed_records.rb +141 -0
  51. data/spec/support/sequel_test_support.rb +46 -13
  52. data/spec/terrestrial/abstract_record_spec.rb +138 -106
  53. data/spec/terrestrial/adapters/sequel_postgres_adapter_spec.rb +138 -0
  54. data/spec/terrestrial/deleted_record_spec.rb +0 -27
  55. data/spec/terrestrial/dirty_map_spec.rb +52 -77
  56. data/spec/terrestrial/functional_pipeline_spec.rb +153 -0
  57. data/spec/terrestrial/inspection_string_spec.rb +61 -0
  58. data/spec/terrestrial/upsert_record_spec.rb +29 -0
  59. data/terrestrial.gemspec +7 -8
  60. metadata +43 -40
  61. data/MissingFeatures.md +0 -64
  62. data/lib/terrestrial/abstract_record.rb +0 -99
  63. data/lib/terrestrial/association_loaders.rb +0 -52
  64. data/lib/terrestrial/upserted_record.rb +0 -15
  65. data/spec/terrestrial/public_conveniencies_spec.rb +0 -63
  66. 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, datastore)
14
- @local_mapping_name = mapping_name
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 :local_mapping_name, :local_mapping, :mappings, :datastore
21
- private :local_mapping_name, :local_mapping, :mappings, :datastore
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: [INFLECTOR.singularize(local_mapping_name), "_id"].join.to_sym,
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
- local_mapping.add_association(
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: INFLECTOR.pluralize(association_name).to_sym,
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
- local_mapping.add_association(
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, through_mapping_name: DEFAULT, association_key: DEFAULT, association_foreign_key: DEFAULT, order_fields: DEFAULT, order_direction: 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: [INFLECTOR.singularize(local_mapping_name), "_id"].join.to_sym,
86
- association_foreign_key: [INFLECTOR.singularize(association_name), "_id"].join.to_sym,
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 through_mapping_name == DEFAULT
107
- through_mapping_name = [
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
- join_table_name = mappings.fetch(through_mapping_name).namespace
114
- config = config
115
- .merge(
116
- through_mapping_name: through_mapping_name,
117
- through_dataset: datastore[join_table_name.to_sym],
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
- local_mapping.add_association(
121
- association_name,
122
- has_many_through_mapper(**config)
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:, through_mapping_name:, through_dataset:, order_fields:, order_direction:)
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: through_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
- require "active_support/inflector"
12
+
9
13
  class Inflector
10
- include ActiveSupport::Inflector
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
- INFLECTOR = Inflector.new
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
- RelationConfigOptionsProxy.new(
43
- method(:add_override).to_proc.curry.call(mapping_name),
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
- (table_mappings + custom_mappings).map { |(mapping_name, overrides)|
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
- mapping(
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, associations|
170
- associations.each do |(assoc_type, assoc_args)|
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: get_fields(table_name),
134
+ fields: all_available_fields(table_name),
182
135
  primary_key: get_primary_key(table_name),
183
- factory: ok_if_it_doesnt_exist_factory(mapping_name),
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 get_fields(table_name)
217
- datastore[table_name].columns
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
- (datastore.tables - [:schema_migrations])
191
+ datastore.relations
230
192
  end
231
193
 
232
194
  def hash_coercion_serializer
233
- ->(o) { o.to_h }
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 mapping(name:, relation_name:, primary_key:, factory:, serializer:, fields:, associations:, subsets:)
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
- FactoryNotFoundError = Class.new(StandardError) do
254
- def initialize(specified)
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
- TableNameNotSpecifiedError = Class.new(StandardError) do
264
- def initialize(mapping_name)
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 raise_if_not_found_factory(name)
271
- ->(attrs) {
272
- class_to_factory(string_to_class(name)).call(attrs)
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 ok_if_it_doesnt_exist_factory(name)
277
- ->(attrs) {
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
- klass_name = INFLECTOR.classify(string)
282
+ Object.const_get(class_name(string))
283
+ end
293
284
 
294
- Object.const_get(klass_name)
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