flex_columns 1.0.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +18 -0
- data/.travis.yml +38 -0
- data/Gemfile +17 -0
- data/LICENSE.txt +22 -0
- data/README.md +124 -0
- data/Rakefile +6 -0
- data/flex_columns.gemspec +72 -0
- data/lib/flex_columns.rb +15 -0
- data/lib/flex_columns/active_record/base.rb +57 -0
- data/lib/flex_columns/contents/column_data.rb +376 -0
- data/lib/flex_columns/contents/flex_column_contents_base.rb +188 -0
- data/lib/flex_columns/definition/field_definition.rb +316 -0
- data/lib/flex_columns/definition/field_set.rb +89 -0
- data/lib/flex_columns/definition/flex_column_contents_class.rb +327 -0
- data/lib/flex_columns/errors.rb +236 -0
- data/lib/flex_columns/has_flex_columns.rb +187 -0
- data/lib/flex_columns/including/include_flex_columns.rb +179 -0
- data/lib/flex_columns/util/dynamic_methods_module.rb +86 -0
- data/lib/flex_columns/util/string_utils.rb +31 -0
- data/lib/flex_columns/version.rb +4 -0
- data/spec/flex_columns/helpers/database_helper.rb +174 -0
- data/spec/flex_columns/helpers/exception_helpers.rb +20 -0
- data/spec/flex_columns/helpers/system_helpers.rb +47 -0
- data/spec/flex_columns/system/basic_system_spec.rb +245 -0
- data/spec/flex_columns/system/bulk_system_spec.rb +153 -0
- data/spec/flex_columns/system/compression_system_spec.rb +218 -0
- data/spec/flex_columns/system/custom_methods_system_spec.rb +120 -0
- data/spec/flex_columns/system/delegation_system_spec.rb +175 -0
- data/spec/flex_columns/system/dynamism_system_spec.rb +158 -0
- data/spec/flex_columns/system/error_handling_system_spec.rb +117 -0
- data/spec/flex_columns/system/including_system_spec.rb +285 -0
- data/spec/flex_columns/system/json_alias_system_spec.rb +171 -0
- data/spec/flex_columns/system/performance_system_spec.rb +218 -0
- data/spec/flex_columns/system/postgres_json_column_type_system_spec.rb +85 -0
- data/spec/flex_columns/system/types_system_spec.rb +93 -0
- data/spec/flex_columns/system/unknown_fields_system_spec.rb +126 -0
- data/spec/flex_columns/system/validations_system_spec.rb +111 -0
- data/spec/flex_columns/unit/active_record/base_spec.rb +32 -0
- data/spec/flex_columns/unit/contents/column_data_spec.rb +520 -0
- data/spec/flex_columns/unit/contents/flex_column_contents_base_spec.rb +253 -0
- data/spec/flex_columns/unit/definition/field_definition_spec.rb +617 -0
- data/spec/flex_columns/unit/definition/field_set_spec.rb +142 -0
- data/spec/flex_columns/unit/definition/flex_column_contents_class_spec.rb +733 -0
- data/spec/flex_columns/unit/errors_spec.rb +297 -0
- data/spec/flex_columns/unit/has_flex_columns_spec.rb +365 -0
- data/spec/flex_columns/unit/including/include_flex_columns_spec.rb +144 -0
- data/spec/flex_columns/unit/util/dynamic_methods_module_spec.rb +105 -0
- data/spec/flex_columns/unit/util/string_utils_spec.rb +23 -0
- 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
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 — 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 — 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,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
|
data/lib/flex_columns.rb
ADDED
@@ -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
|