flex_columns 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (50) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +18 -0
  3. data/.travis.yml +38 -0
  4. data/Gemfile +17 -0
  5. data/LICENSE.txt +22 -0
  6. data/README.md +124 -0
  7. data/Rakefile +6 -0
  8. data/flex_columns.gemspec +72 -0
  9. data/lib/flex_columns.rb +15 -0
  10. data/lib/flex_columns/active_record/base.rb +57 -0
  11. data/lib/flex_columns/contents/column_data.rb +376 -0
  12. data/lib/flex_columns/contents/flex_column_contents_base.rb +188 -0
  13. data/lib/flex_columns/definition/field_definition.rb +316 -0
  14. data/lib/flex_columns/definition/field_set.rb +89 -0
  15. data/lib/flex_columns/definition/flex_column_contents_class.rb +327 -0
  16. data/lib/flex_columns/errors.rb +236 -0
  17. data/lib/flex_columns/has_flex_columns.rb +187 -0
  18. data/lib/flex_columns/including/include_flex_columns.rb +179 -0
  19. data/lib/flex_columns/util/dynamic_methods_module.rb +86 -0
  20. data/lib/flex_columns/util/string_utils.rb +31 -0
  21. data/lib/flex_columns/version.rb +4 -0
  22. data/spec/flex_columns/helpers/database_helper.rb +174 -0
  23. data/spec/flex_columns/helpers/exception_helpers.rb +20 -0
  24. data/spec/flex_columns/helpers/system_helpers.rb +47 -0
  25. data/spec/flex_columns/system/basic_system_spec.rb +245 -0
  26. data/spec/flex_columns/system/bulk_system_spec.rb +153 -0
  27. data/spec/flex_columns/system/compression_system_spec.rb +218 -0
  28. data/spec/flex_columns/system/custom_methods_system_spec.rb +120 -0
  29. data/spec/flex_columns/system/delegation_system_spec.rb +175 -0
  30. data/spec/flex_columns/system/dynamism_system_spec.rb +158 -0
  31. data/spec/flex_columns/system/error_handling_system_spec.rb +117 -0
  32. data/spec/flex_columns/system/including_system_spec.rb +285 -0
  33. data/spec/flex_columns/system/json_alias_system_spec.rb +171 -0
  34. data/spec/flex_columns/system/performance_system_spec.rb +218 -0
  35. data/spec/flex_columns/system/postgres_json_column_type_system_spec.rb +85 -0
  36. data/spec/flex_columns/system/types_system_spec.rb +93 -0
  37. data/spec/flex_columns/system/unknown_fields_system_spec.rb +126 -0
  38. data/spec/flex_columns/system/validations_system_spec.rb +111 -0
  39. data/spec/flex_columns/unit/active_record/base_spec.rb +32 -0
  40. data/spec/flex_columns/unit/contents/column_data_spec.rb +520 -0
  41. data/spec/flex_columns/unit/contents/flex_column_contents_base_spec.rb +253 -0
  42. data/spec/flex_columns/unit/definition/field_definition_spec.rb +617 -0
  43. data/spec/flex_columns/unit/definition/field_set_spec.rb +142 -0
  44. data/spec/flex_columns/unit/definition/flex_column_contents_class_spec.rb +733 -0
  45. data/spec/flex_columns/unit/errors_spec.rb +297 -0
  46. data/spec/flex_columns/unit/has_flex_columns_spec.rb +365 -0
  47. data/spec/flex_columns/unit/including/include_flex_columns_spec.rb +144 -0
  48. data/spec/flex_columns/unit/util/dynamic_methods_module_spec.rb +105 -0
  49. data/spec/flex_columns/unit/util/string_utils_spec.rb +23 -0
  50. metadata +286 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 40b84ad7e37fdca13654ced8a6682554e979c1f9
4
+ data.tar.gz: 9ccc746ceebd8ed7862eac56d2915450ecb0d5e6
5
+ SHA512:
6
+ metadata.gz: ca46c492b3243c5996da41f317b3395863744834cac8747a29ae51b1dd0e513e04631da9914fbbfd14d6f0a50c782eda57972cb52e6f6ace43df096f976ce94c
7
+ data.tar.gz: c9c326762cdfc3711654dd072a20d7f290e52e02c4e7bfbff4a9bc1f044060d7b22074ee816ec612085318907623a1242c0117b0451cc5fa908c387f70790ca5
data/.gitignore ADDED
@@ -0,0 +1,18 @@
1
+ *.gem
2
+ *.rbc
3
+ .bundle
4
+ .config
5
+ .yardoc
6
+ Gemfile.lock
7
+ InstalledFiles
8
+ _yardoc
9
+ coverage
10
+ doc/
11
+ lib/bundler/man
12
+ pkg
13
+ rdoc
14
+ spec/reports
15
+ test/tmp
16
+ test/version_tmp
17
+ tmp
18
+ /spec_database_config.rb
data/.travis.yml ADDED
@@ -0,0 +1,38 @@
1
+ rvm:
2
+ - "1.8.7"
3
+ - "1.9.3"
4
+ - "2.0.0"
5
+ - "jruby-1.7.6"
6
+ env:
7
+ - FLEX_COLUMNS_AR_TEST_VERSION=3.0.20 FLEX_COLUMNS_TRAVIS_CI_DATABASE_TYPE=mysql
8
+ - FLEX_COLUMNS_AR_TEST_VERSION=3.0.20 FLEX_COLUMNS_TRAVIS_CI_DATABASE_TYPE=postgres
9
+ - FLEX_COLUMNS_AR_TEST_VERSION=3.0.20 FLEX_COLUMNS_TRAVIS_CI_DATABASE_TYPE=sqlite
10
+ - FLEX_COLUMNS_AR_TEST_VERSION=3.1.12 FLEX_COLUMNS_TRAVIS_CI_DATABASE_TYPE=mysql
11
+ - FLEX_COLUMNS_AR_TEST_VERSION=3.1.12 FLEX_COLUMNS_TRAVIS_CI_DATABASE_TYPE=postgres
12
+ - FLEX_COLUMNS_AR_TEST_VERSION=3.1.12 FLEX_COLUMNS_TRAVIS_CI_DATABASE_TYPE=sqlite
13
+ - FLEX_COLUMNS_AR_TEST_VERSION=3.2.16 FLEX_COLUMNS_TRAVIS_CI_DATABASE_TYPE=mysql
14
+ - FLEX_COLUMNS_AR_TEST_VERSION=3.2.16 FLEX_COLUMNS_TRAVIS_CI_DATABASE_TYPE=postgres
15
+ - FLEX_COLUMNS_AR_TEST_VERSION=3.2.16 FLEX_COLUMNS_TRAVIS_CI_DATABASE_TYPE=sqlite
16
+ - FLEX_COLUMNS_AR_TEST_VERSION=4.0.2 FLEX_COLUMNS_TRAVIS_CI_DATABASE_TYPE=mysql
17
+ - FLEX_COLUMNS_AR_TEST_VERSION=4.0.2 FLEX_COLUMNS_TRAVIS_CI_DATABASE_TYPE=postgres
18
+ - FLEX_COLUMNS_AR_TEST_VERSION=4.0.2 FLEX_COLUMNS_TRAVIS_CI_DATABASE_TYPE=sqlite
19
+ before_script:
20
+ - mysql -e 'create database myapp_test;'
21
+ - psql -c 'create database myapp_test;' -U postgres
22
+ matrix:
23
+ exclude:
24
+ # ActiveRecord 4.x doesn't support Ruby 1.8.7
25
+ - rvm: 1.8.7
26
+ env: FLEX_COLUMNS_AR_TEST_VERSION=4.0.2 FLEX_COLUMNS_TRAVIS_CI_DATABASE_TYPE=mysql
27
+ - rvm: 1.8.7
28
+ env: FLEX_COLUMNS_AR_TEST_VERSION=4.0.2 FLEX_COLUMNS_TRAVIS_CI_DATABASE_TYPE=postgres
29
+ - rvm: 1.8.7
30
+ env: FLEX_COLUMNS_AR_TEST_VERSION=4.0.2 FLEX_COLUMNS_TRAVIS_CI_DATABASE_TYPE=sqlite
31
+ # There's a bug in ActiveRecord 3.1.x that makes it incompatible with Ruby 2.0
32
+ - rvm: 2.0.0
33
+ env: FLEX_COLUMNS_AR_TEST_VERSION=3.1.12 FLEX_COLUMNS_TRAVIS_CI_DATABASE_TYPE=mysql
34
+ - rvm: 2.0.0
35
+ env: FLEX_COLUMNS_AR_TEST_VERSION=3.1.12 FLEX_COLUMNS_TRAVIS_CI_DATABASE_TYPE=postgres
36
+ - rvm: 2.0.0
37
+ env: FLEX_COLUMNS_AR_TEST_VERSION=3.1.12 FLEX_COLUMNS_TRAVIS_CI_DATABASE_TYPE=sqlite
38
+
data/Gemfile ADDED
@@ -0,0 +1,17 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in flex_columns.gemspec
4
+ gemspec
5
+
6
+ ar_version = ENV['FLEX_COLUMNS_AR_TEST_VERSION']
7
+ ar_version = ar_version.strip if ar_version
8
+
9
+ version_spec = case ar_version
10
+ when nil then nil
11
+ when 'master' then { :git => 'git://github.com/rails/activerecord.git' }
12
+ else "=#{ar_version}"
13
+ end
14
+
15
+ if version_spec
16
+ gem("activerecord", version_spec)
17
+ end
data/LICENSE.txt ADDED
@@ -0,0 +1,22 @@
1
+ Copyright (c) 2013 Andrew Geweke
2
+
3
+ MIT License
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining
6
+ a copy of this software and associated documentation files (the
7
+ "Software"), to deal in the Software without restriction, including
8
+ without limitation the rights to use, copy, modify, merge, publish,
9
+ distribute, sublicense, and/or sell copies of the Software, and to
10
+ permit persons to whom the Software is furnished to do so, subject to
11
+ the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be
14
+ included in all copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
19
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
20
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
21
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
22
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,124 @@
1
+ # flex_columns
2
+
3
+ Schema-free, structured storage inside a RDBMS. Use a `VARCHAR`, `TEXT`, `CLOB`, `BLOB`, or `BINARY` column in your
4
+ schema to store structured data in JSON, while still letting you run validations against that data, build methods on
5
+ top of it, and automatically delegate it to your models. Far more powerful than ActiveRecord's built-in serialization
6
+ mechanism, `flex_columns` gives you the freedom of schemaless databases inside a proven RDBMS.
7
+
8
+ Combined with [`low_card_tables`](https://github.com/ageweke/low_card_tables), allows a RDBMS to represent a wide
9
+ variety of data efficiently and with a great deal of flexibility — build your projects rapidly and effectively
10
+ while relying on the most reliable, manageable, proven data engines out there.
11
+
12
+ Supported platforms:
13
+
14
+ * Ruby 1.8.7, 1.9.3, 2.0.0, and JRuby 1.7.6.
15
+ * ActiveRecord 3.0.20, 3.1.12, 3.2.16, and 4.0.2. (Should be compatible with future versions, as well...just sits on top of the public API of ActiveRecord.)
16
+ * Tested against MySQL, PostgreSQL, and SQLite 3. (Should be compatible with all RDBMSes supported by ActiveRecord.)
17
+
18
+ Current build status: ![Current Build Status](https://api.travis-ci.org/ageweke/flex_columns.png?branch=master)
19
+
20
+ ### Installing flex_columns
21
+
22
+ # Gemfile
23
+ gem 'flex_columns'
24
+
25
+ ### Example
26
+
27
+ As an example — assume table `users` has a `CLOB` column `user_attributes`:
28
+
29
+ class User < ActiveRecord::Base
30
+ ...
31
+ flex_column :user_attributes do
32
+ field :locale
33
+ field :comments_display_mode
34
+ field :custom_page_color
35
+ field :nickname
36
+ end
37
+ ...
38
+ end
39
+
40
+ You can now write code like:
41
+
42
+ user = User.find(...)
43
+ user.locale = :fr_FR
44
+
45
+ case user.comments_display_mode
46
+ when 'threaded' then ...
47
+ when 'linear' then ...
48
+ end
49
+
50
+ ### Robust Example
51
+
52
+ As a snapshot of all possibilities:
53
+
54
+ # Assume we're storing the JSON in a wholly separate table, so we don't have to load it unless we need it...
55
+ class UserDetails < ActiveRecord::Base
56
+ flex_column :user_attributes,
57
+ :compress => 100, # try compressing any JSON >= 100 bytes, but only store compressed if it's smaller
58
+ :visibility => :private, # attributes are private by default
59
+ :prefix => :ua, # sets a prefix for methods delegated from the outer class
60
+ :unknown_fields => :delete # if DB contains fields not declared here, delete those keys when saving
61
+ do
62
+ # automatically adds validations requiring a string that's non-nil
63
+ field :locale, :string, :null => false
64
+ # automatically adds validations requiring the value to be one of the listed values
65
+ field :comments_display_mode, :enum => %w{threaded linear collapsed}
66
+ # in the JSON in the database, the key will be 'cpc', not 'custom_page_color', to save space
67
+ field :custom_page_color, :json => :cpc
68
+ field :nickname
69
+ field :visit_count, :integer
70
+
71
+ # Use the full gamut of Rails validations -- they will run automatically when saving a User
72
+ validates :custom_page_color, :format => { :with => /^\#[0-9a-f]{6}/i, :message => 'must be a valid HTML hex color' }
73
+
74
+ # Define custom methods...
75
+ def french?
76
+ [ :fr_FR, :fr_CA ].include?(locale)
77
+ end
78
+
79
+ # +super+ works correctly in all cases
80
+ def visit_count
81
+ super || 0
82
+ end
83
+
84
+ # You can also access attributes using Hash syntax
85
+ def increment_visit_count!
86
+ self[:visit_count] += 1
87
+ end
88
+ end
89
+ end
90
+
91
+ # And now transparently include it into our User class...
92
+ class User < ActiveRecord::Base
93
+ has_one :user_details
94
+
95
+ include_flex_columns_from :user_details
96
+ end
97
+
98
+ ...and then you can write code like so:
99
+
100
+ user = User.find(...)
101
+
102
+ user.user_attributes.french? # access directly from the column
103
+ user.ua_visit_count # :prefix prefixed the delegated method names with the desired string
104
+
105
+ user.visit_count = 'foo' # sets an invalid value
106
+ user.save # => false; user isn't valid
107
+ user.errors.keys # => :'user_attributes.visit_count'
108
+ user.errors[:'user_attributes.visit_count'] # => [ 'must be a number' ]
109
+
110
+ There's lots more, too:
111
+
112
+ * Complete validations support: the flex-column object includes ActiveModel::Validations, so every single Rails validation (or custom validations) will work perfectly
113
+ * Bulk operations, for avoiding ActiveRecord instantiation (efficiently operate using raw +select_all+ and +activerecord-import+ or similar systems)
114
+ * Transparently compresses JSON data in the column using GZip, if it's typed as binary (`BINARY`, `VARBINARY`, `CLOB`, etc.); you can fully control this, or turn it off if you want
115
+ * Happily allows definition and redefinition of flex columns at any time, for full dynamism and compatibility with development mode of Rails
116
+ * Rich error hierarchy and detailed exception messages &mdash; you will know exactly what went wrong when something goes wrong
117
+ * Include flex columns across associations, with control over exactly what's delegated and visibility of those methods (public or private)
118
+ * Control whether attribute methods generated are public (default) or private (to encourage encapsulation)
119
+ * "Types": automatically adds validations that require fields to comply with database types like +:integer+, +:string+, +:timestamp+, etc.
120
+ * Decide whether to preserve (the default) or delete keys from the underlying JSON that aren't defined in the flex column &mdash; lets you ensure database data is of the highest quality, or be compatible with any other storage mechanisms
121
+
122
+ ### Documentation
123
+
124
+ # Documentation is on [the Wiki](https://github.com/ageweke/flex_columns/wiki)!
data/Rakefile ADDED
@@ -0,0 +1,6 @@
1
+ require "bundler/gem_tasks"
2
+ require "rspec/core/rake_task"
3
+
4
+ RSpec::Core::RakeTask.new(:spec)
5
+
6
+ task :default => :spec
@@ -0,0 +1,72 @@
1
+ # coding: utf-8
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require 'flex_columns/version'
5
+
6
+ Gem::Specification.new do |s|
7
+ s.name = "flex_columns"
8
+ s.version = FlexColumns::VERSION
9
+ s.authors = ["Andrew Geweke"]
10
+ s.email = ["andrew@geweke.org"]
11
+ s.homepage = "https://github.com/ageweke/flex_columns"
12
+ s.description = %q{Schema-free, structured JSON storage inside a RDBMS.}
13
+ s.summary = %q{Schema-free, structured JSON storage inside a RDBMS.}
14
+ s.license = "MIT"
15
+
16
+ s.files = `git ls-files`.split($/)
17
+ s.executables = s.files.grep(%r{^bin/}) { |f| File.basename(f) }
18
+ s.test_files = s.files.grep(%r{^(test|spec|features)/})
19
+ s.require_paths = ["lib"]
20
+
21
+ s.add_dependency 'json'
22
+
23
+ s.add_development_dependency "bundler", "~> 1.3"
24
+ s.add_development_dependency "rake"
25
+ s.add_development_dependency "rspec", "~> 2.14"
26
+
27
+ if (RUBY_VERSION =~ /^1\.9\./ || RUBY_VERSION =~ /^2\.0\./) && ((! defined?(RUBY_ENGINE)) || (RUBY_ENGINE != 'jruby'))
28
+ s.add_development_dependency "pry"
29
+ s.add_development_dependency "pry-debugger"
30
+ s.add_development_dependency "pry-stack_explorer"
31
+ end
32
+
33
+ ar_version = ENV['FLEX_COLUMNS_AR_TEST_VERSION']
34
+ ar_version = ar_version.strip if ar_version
35
+
36
+ version_spec = case ar_version
37
+ when nil then [ ">= 3.0", "<= 4.99.99" ]
38
+ when 'master' then nil
39
+ else [ "=#{ar_version}" ]
40
+ end
41
+
42
+ if version_spec
43
+ s.add_dependency("activerecord", *version_spec)
44
+ end
45
+
46
+ s.add_dependency "activesupport", ">= 3.0", "<= 4.99.99"
47
+
48
+ ar_import_version = case ar_version
49
+ when nil then nil
50
+ when 'master', /^4\.0\./ then '~> 0.4.1'
51
+ when /^3\.0\./ then '~> 0.2.11'
52
+ when /^3\.1\./, /^3\.2\./ then '~> 0.3.1'
53
+ else raise "Don't know what activerecord-import version to require for activerecord version #{ar_version.inspect}!"
54
+ end
55
+
56
+ if ar_import_version
57
+ s.add_dependency("activerecord-import", ar_import_version)
58
+ else
59
+ s.add_dependency("activerecord-import")
60
+ end
61
+
62
+ require File.expand_path(File.join(File.dirname(__FILE__), 'spec', 'flex_columns', 'helpers', 'database_helper'))
63
+ database_gem_name = FlexColumns::Helpers::DatabaseHelper.maybe_database_gem_name
64
+
65
+ # Ugh. Later versions of the 'mysql2' gem are incompatible with AR 3.0.x; so, here, we explicitly trap that case
66
+ # and use an earlier version of that Gem.
67
+ if database_gem_name && database_gem_name == 'mysql2' && ar_version && ar_version =~ /^3\.0\./
68
+ s.add_development_dependency('mysql2', '~> 0.2.0')
69
+ else
70
+ s.add_development_dependency(database_gem_name)
71
+ end
72
+ end
@@ -0,0 +1,15 @@
1
+ require 'active_record'
2
+ require "flex_columns/version"
3
+ require "flex_columns/active_record/base"
4
+
5
+ # The FlexColumns module. Currently, we use this for nothing more than a namespace for our various classes.
6
+ module FlexColumns
7
+ end
8
+
9
+ # Include a very few methods into ActiveRecord::Base. If you declare a flex column using +flex_column+, or include
10
+ # flex columns using +include_flex_columns_from+, we include additional modules into your model class that do more
11
+ # work. This strategy lets us make sure we add as little as possible to ActiveRecord::Base for classes that don't
12
+ # have anything to do with flex columns.
13
+ class ActiveRecord::Base
14
+ include FlexColumns::ActiveRecord::Base
15
+ end
@@ -0,0 +1,57 @@
1
+ require 'active_record'
2
+ require 'active_support/concern'
3
+ require 'flex_columns/has_flex_columns'
4
+ require 'flex_columns/including/include_flex_columns'
5
+
6
+ module FlexColumns
7
+ module ActiveRecord
8
+ # This is the module that gets included into ::ActiveRecord::Base when +flex_columns+ is loaded. (No other changes
9
+ # are made to the ActiveRecord API, except for classes where you've declared +flex_column+ or
10
+ # +include_flex_columns_from+.) All it does is look for calls to our methods, and, when they are called, +include+
11
+ # the correct module and then repeat the call again.
12
+ module Base
13
+ extend ActiveSupport::Concern
14
+
15
+ module ClassMethods
16
+ # Does this class have any flex columns? FlexColumns::HasFlexColumns overrides this to return +true+.
17
+ def has_any_flex_columns?
18
+ false
19
+ end
20
+
21
+ # Declares a flex column. Includes FlexColumns::HasFlexColumns, and then does what looks like an
22
+ # infinitely-recursing call -- but, because Ruby is so badass, this actually calls the method that has just
23
+ # been included into the class, instead (i.e., the one from FlexColumns::HasFlexColumns).
24
+ #
25
+ # See FlexColumns::HasFlexColumns#flex_column for more information.
26
+ def flex_column(*args, &block)
27
+ include FlexColumns::HasFlexColumns
28
+ flex_column(*args, &block)
29
+ end
30
+
31
+ # Includes flex columns from another class. Includes FlexColumns::Including::IncludeFlexColumns, and then does
32
+ # what looks like an infinitely-recursing call -- but, because Ruby is so badass, this actually calls the method
33
+ # that has just been included into the class, instead (i.e., the one from
34
+ # FlexColumns::Including::IncludeFlexColumns).
35
+ #
36
+ # See FlexColumns::Including::IncludeFlexColumns#include_flex_columns_from for more information.
37
+ def include_flex_columns_from(*args, &block)
38
+ include FlexColumns::Including::IncludeFlexColumns
39
+ include_flex_columns_from(*args, &block)
40
+ end
41
+
42
+ def _flex_columns_safe_to_define_method?(method_name)
43
+ base_name = method_name.to_s
44
+ base_name = $1 if base_name =~ /^(.*)=$/i
45
+
46
+ reason = nil
47
+
48
+ reason ||= :column if columns.detect { |c| c.name.to_s == base_name }
49
+ # return false if method_defined?(base_name) || method_defined?("#{base_name}=")
50
+ reason ||= :instance_method if instance_methods(false).map(&:to_s).include?(base_name.to_s)
51
+
52
+ (! reason)
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,376 @@
1
+ require 'flex_columns/errors'
2
+ require 'stringio'
3
+ require 'zlib'
4
+
5
+ module FlexColumns
6
+ module Contents
7
+ # ColumnData is one of the core classes in +flex_columns+. An instance of ColumnData represents the data present
8
+ # in a single row for a single flex column; it stores that data, is used to set and retrieve that data, and can
9
+ # serialize and deserialize itself from and to JSON (with headers and optional compression added for binary storage).
10
+ #
11
+ # Clients do not interact with ColumnData itself; rather, they interact with an instance of a generated subclass
12
+ # of FlexColumnsContentsBase, and it delegates core methods to this object.
13
+ class ColumnData
14
+ # Creates a new instance. +field_set+ is the FlexColumns::Definition::FieldSet that contains the set of fields
15
+ # defined for this flex column; +options+ can contain:
16
+ #
17
+ # [:storage_string] The data present in the column in the database; this can be omitted if creating an instance
18
+ # for a row that has no data, or for a new row.
19
+ # [:data_source] Where did that data come from? This can be any object; it must respond to
20
+ # #describe_flex_column_data_source (no arguments), which should return a String that is used
21
+ # in thrown exceptions to let the client know what data caused the problem; it also must respond to
22
+ # #notification_hash_for_flex_column_data_source (no arguments), which should return a Hash that
23
+ # is used to generate the payload for the ActiveSupport::Notification calls this class makes.
24
+ # (This is, in practice, always an instance of the FlexColumnsContentsBase subclass generated for the
25
+ # column.)
26
+ # [:unknown_fields] Must pass +:preserve+ or +:delete+. If there are keys in the serialized JSON that do not
27
+ # correspond to any fields that the FieldSet knows about, this determines what will happen to
28
+ # that data when re-serializing it to save: +:preserve+ keeps that data, while +:delete+ removes
29
+ # it. (In neither case is that data actually accessible; you must declare a field if you want
30
+ # access to it.)
31
+ # [:length_limit] If present, specifies the maximum length of data that can be stored in the underlying storage
32
+ # mechanism (the column). When serializing data, this object will raise an exception if the
33
+ # serialized form is longer than this limit. This is used to avoid cases where the database might
34
+ # otherwise silently truncate the data being stored (I'm looking at you, MySQL) and hence corrupt
35
+ # stored data.
36
+ # [:storage] This must be +:binary+, +:text+, or :json. If +:text+, standard, uncompressed JSON will always be stored.
37
+ # (It is not possible to store compressed data reliably in a text column, because the database will
38
+ # interpret the bytes as characters and may modify them or raise an exception if byte sequences are
39
+ # present that would be invalid characters in whatever encoding it's using.) If :binary, then a very
40
+ # small header will be written that's just for versioning (currently +FC:01,+), followed by a marker
41
+ # indicating if it's compressed (+1,+) or not (+0,+), followed by either standard, uncompressed JSON
42
+ # encoded in UTF-8 or the GZipped version of the same. If :json, then we assume the database has
43
+ # a native JSON type (like PostgreSQL with sufficiently-recent ActiveRecord and PG gem), and deal in
44
+ # an actual Hash, which the database processes directly.
45
+ # [:compress_if_over_length] If present, must be set to an integer. If +:storage+ is +:binary+ and the JSON string
46
+ # is at least this many bytes long, then this class will compress it before
47
+ # returning its stored data (from #to_stored_data); if the compressed version is at
48
+ # most 95% (MIN_SIZE_REDUCTION_RATIO_FOR_COMPRESSION) as long as the uncompressed
49
+ # version, then the compressed version will be used instead.
50
+ # [:binary_header] Must be +true+ or +false+. If +false+, then, even if +:storage+ is +:binary+, no header will be
51
+ # written to the binary column. (As a consequence, compression will also be disabled, since
52
+ # compression requires the header.)
53
+ # [:null] Must be +true+ or +false+. If +false+, assumes the underlying column in the database is defined as
54
+ # non-NULL (although this is not recommended), and therefore will set an empty string ("") on the column
55
+ # if there's no data in it, rather than SQL +NULL+.
56
+ def initialize(field_set, options = { })
57
+ options.assert_valid_keys(:storage_string, :data_source, :unknown_fields, :length_limit, :storage,
58
+ :compress_if_over_length, :binary_header, :null)
59
+
60
+ @storage_string = options[:storage_string]
61
+ @field_set = field_set
62
+ @data_source = options[:data_source]
63
+ @unknown_fields = options[:unknown_fields]
64
+ @length_limit = options[:length_limit]
65
+ @storage = options[:storage]
66
+ @compress_if_over_length = options[:compress_if_over_length]
67
+ @binary_header = options[:binary_header]
68
+ @null = options[:null]
69
+
70
+ raise ArgumentError, "Invalid JSON string: #{storage_string.inspect}" if storage_string && (! storage_string.kind_of?(String)) && (! storage_string.kind_of?(Hash))
71
+ raise ArgumentError, "Must supply a FieldSet, not: #{field_set.inspect}" unless field_set.kind_of?(FlexColumns::Definition::FieldSet)
72
+ raise ArgumentError, "Must supply a data source, not: #{data_source.inspect}" unless data_source
73
+ raise ArgumentError, "Invalid value for :unknown_fields: #{unknown_fields.inspect}" unless [ :preserve, :delete ].include?(unknown_fields)
74
+ raise ArgumentError, "Invalid value for :length_limit: #{length_limit.inspect}" if length_limit && (! (length_limit.kind_of?(Integer) && length_limit >= 8))
75
+ raise ArgumentError, "Invalid value for :storage: #{storage.inspect}" unless [ :binary, :text, :json ].include?(storage)
76
+ raise ArgumentError, "Invalid value for :compress_if_over_length: #{compress_if_over_length.inspect}" if compress_if_over_length && (! compress_if_over_length.kind_of?(Integer))
77
+ raise ArgumentError, "Invalid value for :binary_header: #{binary_header.inspect}" unless [ true, false ].include?(binary_header)
78
+ raise ArgumentError, "Invalid value for :null: #{null.inspect}" unless [ true, false ].include?(null)
79
+
80
+
81
+ @field_contents_by_field_name = nil
82
+ @unknown_field_contents_by_key = nil
83
+ @touched = false
84
+ end
85
+
86
+ # Returns the data for the given +field_name+. Raises FlexColumns::Errors::NoSuchFieldError if there is no field
87
+ # of the given name. Returns nil if there is such a field, but no data for it.
88
+ def [](field_name)
89
+ field_name = validate_and_deserialize_for_field(field_name)
90
+ field_contents_by_field_name[field_name]
91
+ end
92
+
93
+ # Sets the data for the given +field_name+ to the given +new_value+. Raises FlexColumns::Errors::NoSuchFieldError
94
+ # if there is no field of the given name. Returns +new_value+.
95
+ def []=(field_name, new_value)
96
+ field_name = validate_and_deserialize_for_field(field_name)
97
+
98
+ # We do this for a very good reason. When encoding as JSON, Ruby's JSON library happily accepts Symbols, but
99
+ # encodes them as simple Strings in the JSON. (This makes sense, because JSON doesn't support Symbols.) This
100
+ # means that if you save a value in a flex column as a Symbol, and then re-read that row from the database,
101
+ # you'll get back a String, not the Symbol you put in.
102
+ #
103
+ # Unfortunately, this is different from what you'll get if there is no intervening save/load cycle, where it'd
104
+ # otherwise stay a Symbol. This difference in behavior can be the source of some really annoying bugs. While
105
+ # ActiveRecord has this annoying behavior, this is a chance to clean it up in a small way -- so, if you set a
106
+ # Symbol, we return a String. (And, yes, this has no bearing on Symbols stored nested inside Arrays or Hashes;
107
+ # and that's OK.)
108
+ new_value = new_value.to_s if new_value.kind_of?(Symbol)
109
+
110
+ old_value = field_contents_by_field_name[field_name]
111
+
112
+ @touched = true if old_value != new_value
113
+
114
+ # We deliberately delete from the hash anything that's being set to +nil+; this is so that we don't end up just
115
+ # binding keys to +nil+, and returning them in #keys, etc. (Yes, this means that you can't distinguish a key
116
+ # explicitly set to +nil+ from a key that's not present; this is different from Ruby's semantics for a Hash,
117
+ # but not by very much, and it makes use of +flex_columns+ a whole lot simpler.)
118
+ if new_value == nil
119
+ field_contents_by_field_name.delete(field_name)
120
+ nil
121
+ else
122
+ field_contents_by_field_name[field_name] = new_value
123
+ end
124
+ end
125
+
126
+ # Returns an Array of all field names that are currently set to something.
127
+ def keys
128
+ deserialize_if_necessary!
129
+ field_contents_by_field_name.keys
130
+ end
131
+
132
+ # Does nothing, other than making sure the JSON has been deserialized. This therefore has the effect both of
133
+ # ensuring that the stored data (if any) is valid, and also will remove any unknown keys (on save) if
134
+ # +:unknown_fields+ was set to +:delete+.
135
+ def touch!
136
+ deserialize_if_necessary!
137
+ @touched = true
138
+ end
139
+
140
+ # Has this object been modified in any way?
141
+ def touched?
142
+ !! @touched
143
+ end
144
+
145
+ # Returns a String with the current contents of this object as JSON. (This will deserialize from JSON, if it
146
+ # hasn't already happened.)
147
+ #
148
+ # Always returns a string encoded in UTF-8, if we're running on a Ruby >= 1.9 (that is, with encoding support).
149
+ def to_json
150
+ deserialize_if_necessary!
151
+
152
+ json_hash = to_json_hash
153
+ as_string = json_hash.to_json
154
+ as_string = as_string.encode(Encoding::UTF_8) if as_string.respond_to?(:encode)
155
+
156
+ as_string
157
+ end
158
+
159
+ # Returns the exact String that should be stored in the database -- compressed or not, with header or not, etc.
160
+ # Raises FlexColumns::Errors::JsonTooLongError if the string is too long to fit in the database.
161
+ #
162
+ # (Under PostgreSQL, with appropriate ActiveRecord and PostgreSQL support,)
163
+ def to_stored_data
164
+ out = nil
165
+
166
+ deserialize_if_necessary!
167
+
168
+ return to_json_hash if storage == :json
169
+
170
+ instrument("serialize") do
171
+ if storage == :json
172
+ out = to_json_hash
173
+ else
174
+ out = to_json
175
+
176
+ if out.length < 8 && out =~ /^\s*\{\s*\}\s*$/i
177
+ out = @null ? nil : ""
178
+ else
179
+ out = to_binary_storage(out) if storage == :binary
180
+ end
181
+ end
182
+ end
183
+
184
+ if length_limit && out.length > length_limit
185
+ raise FlexColumns::Errors::JsonTooLongError.new(data_source, length_limit, out)
186
+ end
187
+
188
+ out
189
+ end
190
+
191
+ private
192
+ attr_reader :storage_string, :field_set, :data_source, :unknown_fields, :length_limit, :storage, :compress_if_over_length
193
+ attr_reader :field_contents_by_field_name, :unknown_field_contents_by_key, :binary_header, :null
194
+
195
+ # What's the current version number of our storage format? Because we only have a single version right now,
196
+ # this is also the only version we accept.
197
+ FLEX_COLUMN_CURRENT_VERSION_NUMBER = 1
198
+
199
+ # What maximum fraction of the uncompressed size does a compressed string have to be before we use it in preference
200
+ # to the uncompressed string?
201
+ MIN_SIZE_REDUCTION_RATIO_FOR_COMPRESSION = 0.95
202
+
203
+ # Returns a Hash with exactly the key-to-value mappings that we'd store as JSON -- that is, uses fields'
204
+ # JSON storage aliases, not field names, and omits unknown fields if <tt>unknown_fields == :delete</tt>.
205
+ def to_json_hash
206
+ json_hash = { }
207
+ json_hash.merge!(unknown_field_contents_by_key) unless unknown_fields == :delete
208
+
209
+ field_contents_by_field_name.each do |field_name, field_contents|
210
+ storage_name = field_set.field_named(field_name).json_storage_name
211
+ json_hash[storage_name] = field_contents
212
+ end
213
+
214
+ json_hash
215
+ end
216
+
217
+ # Fires the appropriate +flex_columns+ notification with the given +name+, any +additional+ options in the payload,
218
+ # wrapped around the supplied block.
219
+ def instrument(name, additional = { }, &block)
220
+ ::ActiveSupport::Notifications.instrument("flex_columns.#{name}", data_source.notification_hash_for_flex_column_data_source.merge(additional), &block)
221
+ end
222
+
223
+ # Given a +field_name+, ensures that that is, in fact, a valid field name, and that we have been deserialized.
224
+ # Used for implementing #[] and #[]=.
225
+ def validate_and_deserialize_for_field(field_name)
226
+ field = field_set.field_named(field_name)
227
+ unless field
228
+ raise FlexColumns::Errors::NoSuchFieldError.new(data_source, field_name, field_set.all_field_names)
229
+ end
230
+
231
+ deserialize_if_necessary!
232
+
233
+ field.field_name
234
+ end
235
+
236
+ # Given a JSON string, returns the appropriate binary-storage string. This is the method that figures out
237
+ # whether we should compress the data or not and applies the binary header, if appropriate.
238
+ def to_binary_storage(json_string)
239
+ json_string = json_string.force_encoding(Encoding::BINARY) if json_string.respond_to?(:force_encoding)
240
+ return json_string if (! binary_header)
241
+
242
+ header = "FC:%02d," % FLEX_COLUMN_CURRENT_VERSION_NUMBER
243
+
244
+ json_length = if json_string.respond_to?(:bytesize) then json_string.bytesize else json_string.length end
245
+
246
+ if compress_if_over_length && json_length > compress_if_over_length
247
+ compressed = compress(json_string)
248
+ compressed.force_encoding(Encoding::BINARY) if compressed.respond_to?(:force_encoding)
249
+ compressed = header + "1," + compressed
250
+ compressed.force_encoding(Encoding::BINARY) if compressed.respond_to?(:force_encoding)
251
+ end
252
+
253
+ compressed_length = if compressed
254
+ if compressed.respond_to?(:bytesize)
255
+ compressed.bytesize
256
+ else
257
+ compressed.length
258
+ end
259
+ end
260
+
261
+ if compressed_length && compressed_length < (MIN_SIZE_REDUCTION_RATIO_FOR_COMPRESSION * json_length)
262
+ compressed
263
+ else
264
+ header + "0," + json_string
265
+ end
266
+ end
267
+
268
+ # Compresses a string with GZip and returns its compressed representation.
269
+ def compress(json_string)
270
+ stream = StringIO.new("w")
271
+ writer = Zlib::GzipWriter.new(stream)
272
+ writer.write(json_string)
273
+ writer.close
274
+
275
+ stream.string
276
+ end
277
+
278
+ # Decompresses a GZipped string and returns the decompressed version.
279
+ def decompress(data, raw_data)
280
+ begin
281
+ input = StringIO.new(data, "r")
282
+ reader = Zlib::GzipReader.new(input)
283
+ reader.read
284
+ rescue Zlib::GzipFile::Error => gze
285
+ raise FlexColumns::Errors::InvalidCompressedDataInDatabaseError.new(data_source, raw_data, gze)
286
+ end
287
+ end
288
+
289
+ # Given a storage string, returns a pure-JSON string. This involves looking for a header, and, if it's present,
290
+ # validating it and uncompressing the content (if compressed).
291
+ def from_stored_data(storage_string)
292
+ if storage_string =~ /^(FC:(\d+),(\d+),)/i
293
+ prefix = $1
294
+ version_number = Integer($2)
295
+ compressed = Integer($3)
296
+ remaining_data = storage_string[prefix.length..-1]
297
+
298
+ if version_number > FLEX_COLUMN_CURRENT_VERSION_NUMBER
299
+ raise FlexColumns::Errors::InvalidFlexColumnsVersionNumberInDatabaseError.new(
300
+ data_source, storage_string, version_number, FLEX_COLUMN_CURRENT_VERSION_NUMBER)
301
+ end
302
+
303
+ case compressed
304
+ when 0 then remaining_data
305
+ when 1 then decompress(remaining_data, storage_string)
306
+ else raise FlexColumns::Errors::InvalidDataInDatabaseError.new(
307
+ data_source, storage_string, "the compression number was #{compressed.inspect}, not 0 or 1.")
308
+ end
309
+ else
310
+ storage_string
311
+ end
312
+ end
313
+
314
+ # Parses JSON. This just adds exception handling that tells you exactly where the failure was.
315
+ def parse_json(json)
316
+ out = begin
317
+ JSON.parse(json)
318
+ rescue ::JSON::ParserError => pe
319
+ raise FlexColumns::Errors::UnparseableJsonInDatabaseError.new(data_source, json, pe)
320
+ end
321
+
322
+ unless out.kind_of?(Hash)
323
+ raise FlexColumns::Errors::InvalidJsonInDatabaseError.new(data_source, json, out)
324
+ end
325
+
326
+ out
327
+ end
328
+
329
+ # Given a hash returned by parsing JSON, stores the data away in either @field_contents_by_field_name or
330
+ # @unknown_field_contents_by_key, depending on whether the data matches one of our fields or not.
331
+ def store_fields!(parsed_hash)
332
+ @field_contents_by_field_name = { }
333
+ @unknown_field_contents_by_key = { }
334
+
335
+ parsed_hash.each do |field_name, field_value|
336
+ field = field_set.field_with_json_storage_name(field_name)
337
+ if field
338
+ @field_contents_by_field_name[field.field_name] = field_value
339
+ else
340
+ @unknown_field_contents_by_key[field_name] = field_value
341
+ end
342
+ end
343
+ end
344
+
345
+ # If we haven't yet deserialized the JSON string, do it now, and store the data appropriately. This also
346
+ # checks for a validly-encoded string.
347
+ def deserialize_if_necessary!
348
+ unless field_contents_by_field_name
349
+ raw_data = storage_string || ''
350
+
351
+ # PostgreSQL's JSON data type, combined with recent-enough adapters and ActiveRecord, will return JSON as a
352
+ # Hash directly from the driver (!).
353
+ if raw_data.kind_of?(Hash)
354
+ store_fields!(raw_data)
355
+ return
356
+ end
357
+
358
+ if raw_data.respond_to?(:valid_encoding?) && (! raw_data.valid_encoding?)
359
+ raise FlexColumns::Errors::IncorrectlyEncodedStringInDatabaseError.new(data_source, raw_data)
360
+ end
361
+
362
+ if raw_data.strip.length > 0
363
+ parsed = instrument("deserialize", :raw_data => raw_data) do
364
+ parse_json(from_stored_data(raw_data))
365
+ end
366
+
367
+ store_fields!(parsed)
368
+ else
369
+ @field_contents_by_field_name = { }
370
+ @unknown_field_contents_by_key = { }
371
+ end
372
+ end
373
+ end
374
+ end
375
+ end
376
+ end