squint 0.0.3 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: 861884fea5cb7280a63e0759876ae2aae7a00750
4
- data.tar.gz: 6e2a4f8b024b165ef215e3a135063fdb235002f4
3
+ metadata.gz: df371ee0e87ea3faac9e9fc9a41b348f0ff30424
4
+ data.tar.gz: d44f014f0e8fa11d00f23bf19a6fa29d4954b035
5
5
  SHA512:
6
- metadata.gz: c8403702ac72aaef517fb97ef8042f9bb28170cddf4edbe6e11c655993f29301206d2bd7b70bf2c943fb5f6ef7f782141d044acd1f61ad539995575b7d4c238b
7
- data.tar.gz: 7f355faa0113834c322e9c13093ff556914bfff0438e809144eedfb0efe729b72cc6174f72fa348281c03ea65739ba5921720ca414029cbccc32bd502afb1fb3
6
+ metadata.gz: b8459d887335bb272cbd9d0fb8e31f012d2c573e710c3dc2b2a4d6e07057187171c7dbc8e9fbd873cec58741199aae5804861698c9eba2bdfaf5fd6ae6e3f69b
7
+ data.tar.gz: d000d6ebde399bb8edaead6c27e43aeb78575b81072eb9d0f0aa96e860cfb61bf45d0e169a1830bf6f7428e4c0511cd4798ac1ae900f882a636276d3a35a2345
@@ -0,0 +1,86 @@
1
+ {
2
+ "projectName": "squint",
3
+ "projectOwner": "ProctorU",
4
+ "files": [
5
+ "readme.md"
6
+ ],
7
+ "imageSize": 100,
8
+ "commit": true,
9
+ "contributors": [
10
+ {
11
+ "login": "chevinbrown",
12
+ "name": "Kevin Brown",
13
+ "avatar_url": "https://avatars2.githubusercontent.com/u/864581?v=3",
14
+ "profile": "https://github.com/chevinbrown",
15
+ "contributions": [
16
+ "design",
17
+ "review"
18
+ ]
19
+ },
20
+ {
21
+ "login": "king601",
22
+ "name": "Andrew Fomera",
23
+ "avatar_url": "https://avatars2.githubusercontent.com/u/1741179?v=3",
24
+ "profile": "http://andrewfomera.com",
25
+ "contributions": [
26
+ "review"
27
+ ]
28
+ },
29
+ {
30
+ "login": "Jaehdawg",
31
+ "name": "Matthew Jaeh",
32
+ "avatar_url": "https://avatars2.githubusercontent.com/u/1785682?v=3",
33
+ "profile": "https://github.com/Jaehdawg",
34
+ "contributions": [
35
+ "design",
36
+ "review"
37
+ ]
38
+ },
39
+ {
40
+ "login": "rthbound",
41
+ "name": "Ryan T. Hosford",
42
+ "avatar_url": "https://avatars2.githubusercontent.com/u/708692?v=3",
43
+ "profile": "https://github.com/rthbound",
44
+ "contributions": [
45
+ "code"
46
+ ]
47
+ },
48
+ {
49
+ "login": "licatajustin",
50
+ "name": "Justin Licata",
51
+ "avatar_url": "https://avatars0.githubusercontent.com/u/3933204?v=3",
52
+ "profile": "https://twitter.com/justinlicata",
53
+ "contributions": [
54
+ "code",
55
+ "design",
56
+ "doc",
57
+ "review"
58
+ ]
59
+ },
60
+ {
61
+ "login": "dwilkins",
62
+ "name": "David H. Wilkins",
63
+ "avatar_url": "https://avatars2.githubusercontent.com/u/97011?v=3",
64
+ "profile": "http://conecuh.com",
65
+ "contributions": [
66
+ "question",
67
+ "bug",
68
+ "code",
69
+ "design",
70
+ "doc",
71
+ "example",
72
+ "review",
73
+ "test"
74
+ ]
75
+ },
76
+ {
77
+ "login": "TheJayWright",
78
+ "name": "Jay Wright",
79
+ "avatar_url": "https://avatars3.githubusercontent.com/u/19173815?v=3",
80
+ "profile": "https://github.com/TheJayWright",
81
+ "contributions": [
82
+ "review"
83
+ ]
84
+ }
85
+ ]
86
+ }
@@ -3,23 +3,34 @@ require 'active_support/concern'
3
3
  # Squint json, jsonb, hstore queries
4
4
  module Squint
5
5
  extend ActiveSupport::Concern
6
- include ::ActiveRecord::QueryMethods
6
+ if ActiveRecord::VERSION::STRING < '5'
7
+ include ::ActiveRecord::QueryMethods
8
+ end
7
9
 
8
10
  module WhereMethods
9
- # Args may be passed to build_where like:
11
+ # Args may be passed to build/build_where like:
10
12
  # build_where(jsonb_column: {key1: value1})
11
13
  # build_where(jsonb_column: {key1: value1}, jsonb_column: {key2: value2})
12
14
  # build_where(jsonb_column: {key1: value1}, regular_column: value)
13
15
  # build_where(jsonb_column: {key1: value1}, association: {column: value))
14
- def build_where(*args)
16
+ if ActiveRecord::VERSION::STRING > '5'
17
+ method_name = :build
18
+ elsif ActiveRecord::VERSION::STRING < '5'
19
+ method_name = :build_where
20
+ end
21
+ send :define_method, method_name do |*args|
22
+ # For Rails 5, we end up monkey patching WhereClauseFactory for everyone
23
+ # so need to return super if our methods aren't on the AR class
24
+ # doesn't hurt for 4.2.x either
25
+ return super(*args) unless klass.respond_to?(:squint_hash_field_reln)
15
26
  save_args = []
16
27
  reln = args.inject([]) do |memo, arg|
17
28
  if arg.is_a?(Hash)
18
29
  arg.keys.each do |key|
19
30
  if arg[key].is_a?(Hash) && HASH_DATA_COLUMNS[key]
20
- memo << hash_field_reln(key => arg[key])
31
+ memo << klass.squint_hash_field_reln(key => arg[key])
21
32
  else
22
- memo += super(key => arg[key])
33
+ save_args << { key => arg[key] }
23
34
  end
24
35
  end
25
36
  elsif arg.present?
@@ -27,15 +38,51 @@ module Squint
27
38
  end
28
39
  memo
29
40
  end
41
+ if ActiveRecord::VERSION::STRING > '5'
42
+ reln = ActiveRecord::Relation::WhereClause.new(reln, [])
43
+ save_args << [] if save_args.size == 1
44
+ end
30
45
  reln += super(*save_args) unless save_args.empty?
31
46
  reln
32
47
  end
48
+ end
49
+
50
+ included do |base|
51
+ if ActiveRecord::VERSION::STRING < '5'
52
+ ar_reln_module = base::ActiveRecord_Relation
53
+ ar_association_module = base::ActiveRecord_AssociationRelation
54
+ elsif ActiveRecord::VERSION::STRING > '5.1'
55
+ # ActiveRecord_Relation is now a private_constant in 5.1.x
56
+ ar_reln_module = base.relation_delegate_class(ActiveRecord::Relation)::WhereClauseFactory
57
+ ar_association_module = nil
58
+ elsif ActiveRecord::VERSION::STRING > '5.0'
59
+ ar_reln_module = base::ActiveRecord_Relation::WhereClauseFactory
60
+ ar_association_module = nil
61
+ # ar_association_module = base::ActiveRecord_AssociationRelation
62
+ end
63
+
64
+ # put together a list of columns in this model
65
+ # that are hstore, json, or jsonb and will benefit from
66
+ # searchability
67
+ HASH_DATA_COLUMNS = base.columns_hash.keys.map do |col_name|
68
+ if %w[hstore json jsonb].include?(base.columns_hash[col_name].sql_type)
69
+ [col_name.to_sym, base.columns_hash[col_name].sql_type]
70
+ end
71
+ end.compact.to_h
33
72
 
34
- # hash_field_reln
73
+ ar_reln_module.class_eval do
74
+ prepend WhereMethods
75
+ end
76
+
77
+ ar_association_module.try(:class_eval) do
78
+ prepend WhereMethods
79
+ end
80
+
81
+ # squint_hash_field_reln
35
82
  # return an Arel object with the appropriate query
36
83
  # Strings want to be a SQL Literal, other things can be
37
84
  # passed in bare to the eq or in operator
38
- def hash_field_reln(*args)
85
+ def self.squint_hash_field_reln(*args)
39
86
  temp_attr = args[0]
40
87
  contains_nil = false
41
88
  column_type = HASH_DATA_COLUMNS[args[0].keys.first]
@@ -100,109 +147,84 @@ module Squint
100
147
  # specified as a query value
101
148
  if check_attr_missing
102
149
  reln = if column_type == 'hstore'.freeze
103
- hstore_element_missing(column_name_segments, reln)
150
+ squint_hstore_element_missing(column_name_segments, reln)
104
151
  else
105
- jsonb_element_missing(column_name_segments, reln)
152
+ squint_jsonb_element_missing(column_name_segments, reln)
106
153
  end
107
154
  end
108
155
  reln
109
156
  end
110
- end
111
-
112
- included do |base|
113
- ar_reln_module = base::ActiveRecord_Relation
114
- ar_association_module = base::ActiveRecord_AssociationRelation
115
157
 
116
- # put together a list of columns in this model
117
- # that are hstore, json, or jsonb and will benefit from
118
- # searchability
119
- HASH_DATA_COLUMNS = base.columns_hash.keys.map do |col_name|
120
- if %w[hstore json jsonb].include?(base.columns_hash[col_name].sql_type)
121
- [col_name.to_sym, base.columns_hash[col_name].sql_type]
158
+ def self.squint_storext_default?(temp_attr, attribute_sym)
159
+ return false unless respond_to?(:storext_definitions)
160
+ if storext_definitions.keys.include?(attribute_sym) &&
161
+ !(storext_definitions[attribute_sym][:opts] &&
162
+ storext_definitions[attribute_sym][:opts][:default]).nil? &&
163
+ [temp_attr].compact.map(&:to_s).
164
+ flatten.
165
+ include?(storext_definitions[attribute_sym][:opts][:default].to_s)
166
+ true
122
167
  end
123
- end.compact.to_h
124
-
125
- ar_reln_module.class_eval do
126
- prepend WhereMethods
127
168
  end
128
169
 
129
- ar_association_module.class_eval do
130
- prepend WhereMethods
170
+ def self.squint_hstore_element_exists(element, attribute_hash_column, value)
171
+ Arel::Nodes::Equality.new(
172
+ Arel::Nodes::NamedFunction.new(
173
+ "exist",
174
+ [arel_table[Arel::Nodes::SqlLiteral.new(attribute_hash_column)],
175
+ Arel::Nodes::SqlLiteral.new(element)]
176
+ ), value
177
+ )
131
178
  end
132
179
 
133
- def self.jsonb_element_missing(column_name_segments, reln)
180
+ def self.squint_hstore_element_missing(column_name_segments, reln)
134
181
  element = column_name_segments.pop
135
182
  attribute_hash_column = column_name_segments.join('->'.freeze)
136
183
  # Query generated is equals default or attribute present is null or equals false
137
- # * Is null happens when the the whole column is null
184
+ # * Is null happens the the column is null
138
185
  # * equals false is when the column has jsonb data, but the key doesn't exist
139
186
  # ("posts"."storext_attributes"->>'is_awesome' = 'false' OR
140
- # (("posts"."storext_attributes" ? 'is_awesome') IS NULL OR
141
- # ("posts"."storext_attributes" ? 'is_awesome') = FALSE)
187
+ # (exists("posts"."storext_attributes", 'is_awesome') IS NULL OR
188
+ # exists("posts"."storext_attributes", 'is_awesome') = FALSE)
142
189
  # )
143
190
  Arel::Nodes::Grouping.new(
144
191
  reln.or(
145
192
  Arel::Nodes::Grouping.new(
146
- Arel::Nodes::Equality.new(
147
- Arel::Nodes::Grouping.new(
148
- Arel::Nodes::InfixOperation.new(
149
- Arel::Nodes::SqlLiteral.new('?'),
150
- arel_table[Arel::Nodes::SqlLiteral.new(attribute_hash_column)],
151
- Arel::Nodes::SqlLiteral.new(element)
152
- )
153
- ), nil
154
- ).or(
155
- Arel::Nodes::Equality.new(
156
- Arel::Nodes::Grouping.new(
157
- Arel::Nodes::InfixOperation.new(
158
- Arel::Nodes::SqlLiteral.new('?'),
159
- arel_table[Arel::Nodes::SqlLiteral.new(attribute_hash_column)],
160
- Arel::Nodes::SqlLiteral.new(element)
161
- )
162
- ), Arel::Nodes::False.new
163
- )
164
- )
193
+ squint_hstore_element_exists(element, attribute_hash_column, Arel::Nodes::False.new)
194
+ ).or(
195
+ squint_hstore_element_exists(element, attribute_hash_column, nil)
165
196
  )
166
197
  )
167
198
  )
168
199
  end
169
200
 
170
- def self.squint_storext_default?(temp_attr, attribute_sym)
171
- return false unless respond_to?(:storext_definitions)
172
- if storext_definitions.keys.include?(attribute_sym) &&
173
- !storext_definitions[attribute_sym].dig(:opts, :default).nil? &&
174
- [temp_attr].compact.map(&:to_s).
175
- flatten.
176
- include?(storext_definitions[attribute_sym][:opts][:default].to_s)
177
- true
178
- end
201
+ def self.squint_jsonb_element_equality(element, attribute_hash_column, value)
202
+ Arel::Nodes::Equality.new(
203
+ Arel::Nodes::Grouping.new(
204
+ Arel::Nodes::InfixOperation.new(
205
+ Arel::Nodes::SqlLiteral.new('?'),
206
+ arel_table[Arel::Nodes::SqlLiteral.new(attribute_hash_column)],
207
+ Arel::Nodes::SqlLiteral.new(element)
208
+ )
209
+ ), value
210
+ )
179
211
  end
180
212
 
181
- def self.hstore_element_missing(column_name_segments, reln)
213
+ def self.squint_jsonb_element_missing(column_name_segments, reln)
182
214
  element = column_name_segments.pop
183
215
  attribute_hash_column = column_name_segments.join('->'.freeze)
184
216
  # Query generated is equals default or attribute present is null or equals false
185
- # * Is null happens the the column is null
217
+ # * Is null happens when the the whole column is null
186
218
  # * equals false is when the column has jsonb data, but the key doesn't exist
187
219
  # ("posts"."storext_attributes"->>'is_awesome' = 'false' OR
188
- # (exists("posts"."storext_attributes", 'is_awesome') IS NULL OR
189
- # exists("posts"."storext_attributes", 'is_awesome') = FALSE)
220
+ # (("posts"."storext_attributes" ? 'is_awesome') IS NULL OR
221
+ # ("posts"."storext_attributes" ? 'is_awesome') = FALSE)
190
222
  # )
191
223
  Arel::Nodes::Grouping.new(
192
224
  reln.or(
193
225
  Arel::Nodes::Grouping.new(
194
- Arel::Nodes::NamedFunction.new(
195
- "exist",
196
- [arel_table[Arel::Nodes::SqlLiteral.new(attribute_hash_column)],
197
- Arel::Nodes::SqlLiteral.new(element)]
198
- ).eq(Arel::Nodes::False.new)
199
- ).or(
200
- Arel::Nodes::Equality.new(
201
- Arel::Nodes::NamedFunction.new(
202
- "exist",
203
- [arel_table[Arel::Nodes::SqlLiteral.new(attribute_hash_column)],
204
- Arel::Nodes::SqlLiteral.new(element)]
205
- ), nil
226
+ squint_jsonb_element_equality(element, attribute_hash_column, nil).or(
227
+ squint_jsonb_element_equality(element, attribute_hash_column, Arel::Nodes::False.new)
206
228
  )
207
229
  )
208
230
  )
@@ -1,3 +1,3 @@
1
1
  module Squint
2
- VERSION = "0.0.3".freeze
2
+ VERSION = "1.0.0".freeze
3
3
  end
@@ -0,0 +1,185 @@
1
+ <p align="center">
2
+ <a href="https://twitter.com/ProctorUEng">
3
+ <img src="https://s3-us-west-2.amazonaws.com/dev-team-resources/squint-wordmark.svg" width=198 height=72>
4
+ </a>
5
+
6
+ <p align="center">
7
+ Search PostgreSQL <code>jsonb</code> and <code>hstore</code> columns.
8
+ </p>
9
+ </p>
10
+
11
+ <br>
12
+
13
+ > Full database searching inside columns containing semi-structured data like `json`,
14
+ `jsonb` and `hstore`. <strong>Compatible with the awesome
15
+ <a href="https://github.com/G5/storext">storext</a> gem</strong>.
16
+
17
+ ## Table of contents
18
+
19
+ - [Status](#status)
20
+ - [Quick start](#quick-start)
21
+ - [Performance](#performance)
22
+ - [Storext attributes](#storext-attributes)
23
+ - [Developing](#developing)
24
+ - [Contributors](#contributors)
25
+ - [Credits](#credits)
26
+
27
+ ## Status
28
+ [![All Contributors](https://img.shields.io/badge/all_contributors-7-orange.svg?style=flat-square)](#contributors)
29
+ [![CircleCI](https://circleci.com/gh/ProctorU/squint.svg?style=svg)](https://circleci.com/gh/ProctorU/squint)
30
+
31
+ ## Quick Start
32
+
33
+ Add to your Gemfile:
34
+
35
+ ```ruby
36
+ gem 'squint'
37
+ ```
38
+
39
+ Include it in your models:
40
+
41
+ ```ruby
42
+ class Post < ActiveRecord::Base
43
+ include Squint
44
+ # ...
45
+ end
46
+ ```
47
+
48
+ Assuming a table with the following structure:
49
+ ```
50
+ Table "public.posts"
51
+ Column | Type | Modifiers
52
+ ---------------------------+-----------------------------+----------------------------------------------------
53
+ id | integer | not null default nextval('posts_id_seq'::regclass)
54
+ title | character varying |
55
+ body | character varying |
56
+ request_info | jsonb |
57
+ properties | hstore |
58
+ storext_jsonb_attributes | jsonb |
59
+ storext_hstore_attributes | jsonb |
60
+ created_at | timestamp without time zone | not null
61
+ updated_at | timestamp without time zone | not null
62
+ Indexes:
63
+ "posts_pkey" PRIMARY KEY, btree (id)
64
+ ```
65
+
66
+ In your code use queries like:
67
+ ```ruby
68
+ Post.where(properties: { referer: 'http://example.com/one' } )
69
+ # SELECT "posts".* FROM "posts" WHERE "posts"."properties"->'referer' = 'http://example.com/one'
70
+
71
+ Post.where(properties: { referer: nil } )
72
+ # SELECT "posts".* FROM "posts" WHERE "posts"."properties"->'referer' IS NULL
73
+
74
+ Post.where(properties: { referer: ['http://example.com/one',nil] } )
75
+ # SELECT "posts".* FROM "posts" WHERE ("posts"."properties"->'referer' = 'http://example.com/one'
76
+ # OR "posts"."properties"->'referer' IS NULL)
77
+
78
+ Post.where(request_info: { referer: ['http://example.com/one',nil] } )
79
+ # SELECT "posts".* FROM "posts" WHERE ("posts"."request_info"->>'referer' = 'http://example.com/one'
80
+ # OR "posts"."request_info"->>'referer' IS NULL)
81
+ ```
82
+
83
+ Squint only operates on json, jsonb and hstore columns. ActiveRecord
84
+ will throw a StatementInvalid exception like always if the column type is unsupported by
85
+ Squint.
86
+
87
+ ```ruby
88
+ Post.where(title: { not_there: "any value will do" } )
89
+ ```
90
+
91
+ ```
92
+ ActiveRecord::StatementInvalid: PG::UndefinedTable: ERROR: missing FROM-clause entry for table "title"
93
+ LINE 1: SELECT COUNT(*) FROM "posts" WHERE "title"."not_there" = 'an...
94
+ ^
95
+ : SELECT COUNT(*) FROM "posts" WHERE "title"."not_there" = 'any value will do'
96
+ ```
97
+
98
+ ## Performance
99
+ To get the most performance out searching jsonb/hstore attributes, add a GIN (preferred) or
100
+ GIST index to those columns. Find out more
101
+ [here](https://www.postgresql.org/docs/9.5/static/textsearch-indexes.html)
102
+
103
+ TL;DR:
104
+
105
+ SQL: 'CREATE INDEX name ON table USING GIN (column);'
106
+
107
+ Rails Migration: `add_index(:table, :column_name, using: 'gin')`
108
+
109
+
110
+ ## Storext attributes
111
+ Assuming the database schema above and a model like so:
112
+ ```ruby
113
+ class Post < ActiveRecord::Base
114
+ include Storext.model
115
+ include Squint
116
+
117
+ store_attribute :storext_jsonb_attributes, :zip_code, String, default: '90210'
118
+ store_attribute :storext_jsonb_attributes, :friend_count, Integer, default: 0
119
+ end
120
+ ```
121
+
122
+ Example using StoreXT with a default value:
123
+ ```ruby
124
+ Post.where(storext_jsonb_attributes: { zip_code: '90210' } )
125
+ # -- jsonb
126
+ # SELECT "posts".* FROM "posts" WHERE ("posts"."storext_jsonb_attributes"->>'zip_code' = '90210' OR
127
+ # (("posts"."storext_jsonb_attributes" ? 'zip_code') IS NULL OR
128
+ # ("posts"."storext_jsonb_attributes" ? 'zip_code') = FALSE))
129
+ # -- hstore
130
+ # SELECT "posts".* FROM "posts" WHERE ("posts"."storext_hstore_attributes"->'zip_code' = '90210' OR
131
+ # ((exist("posts"."storext_hstore_attributes", 'zip_code') = FALSE) OR
132
+ # exist("posts"."storext_hstore_attributes", 'zip_code') IS NULL))
133
+ #
134
+ #
135
+ ```
136
+ If (as in the example above) the default value for the StoreXT attribute is specified, then extra
137
+ checks for missing column ( `("posts"."storext_jsonb_attributes" ? 'zip_code') IS NULL` ) or
138
+ missing key ( `("posts"."storext_jsonb_attributes" ? 'zip_code') = FALSE)` ) are added
139
+
140
+ When non-default storext values are specified, these extra checks won't be added.
141
+
142
+ The Postgres SQL for jsonb and hstore is different. No support for checking for missing `json`
143
+ columns exists, so don't use those with StoreXT + Squint
144
+
145
+ ## Developing
146
+
147
+ 1. Thank you!
148
+ 1. Clone the repository
149
+ 1. `bundle`
150
+ 1. `bundle exec rake --rakefile test/dummy/Rakefile db:setup` # create the db for tests
151
+ 1. `bundle exec rake` # run the tests
152
+ 1. make your changes in a thoughtfully named branch
153
+ 1. ensure good test coverage
154
+ 1. submit a Pull Request
155
+
156
+ ## Contributors
157
+
158
+ Thanks goes to these wonderful people ([emoji key](https://github.com/kentcdodds/all-contributors#emoji-key)):
159
+
160
+ <!-- ALL-CONTRIBUTORS-LIST:START - Do not remove or modify this section -->
161
+ | [<img src="https://avatars2.githubusercontent.com/u/864581?v=3" width="100px;"/><br /><sub>Kevin Brown</sub>](https://github.com/chevinbrown)<br />[🎨](#design-chevinbrown "Design") [👀](#review-chevinbrown "Reviewed Pull Requests") | [<img src="https://avatars2.githubusercontent.com/u/1741179?v=3" width="100px;"/><br /><sub>Andrew Fomera</sub>](http://andrewfomera.com)<br />[👀](#review-king601 "Reviewed Pull Requests") | [<img src="https://avatars2.githubusercontent.com/u/1785682?v=3" width="100px;"/><br /><sub>Matthew Jaeh</sub>](https://github.com/Jaehdawg)<br />[🎨](#design-Jaehdawg "Design") [👀](#review-Jaehdawg "Reviewed Pull Requests") | [<img src="https://avatars2.githubusercontent.com/u/708692?v=3" width="100px;"/><br /><sub>Ryan T. Hosford</sub>](https://github.com/rthbound)<br />[💻](https://github.com/ProctorU/squint/commits?author=rthbound "Code") | [<img src="https://avatars0.githubusercontent.com/u/3933204?v=3" width="100px;"/><br /><sub>Justin Licata</sub>](https://twitter.com/justinlicata)<br />[💻](https://github.com/ProctorU/squint/commits?author=licatajustin "Code") [🎨](#design-licatajustin "Design") [📖](https://github.com/ProctorU/squint/commits?author=licatajustin "Documentation") [👀](#review-licatajustin "Reviewed Pull Requests") | [<img src="https://avatars2.githubusercontent.com/u/97011?v=3" width="100px;"/><br /><sub>David H. Wilkins</sub>](http://conecuh.com)<br />[💬](#question-dwilkins "Answering Questions") [🐛](https://github.com/ProctorU/squint/issues?q=author%3Adwilkins "Bug reports") [💻](https://github.com/ProctorU/squint/commits?author=dwilkins "Code") [🎨](#design-dwilkins "Design") [📖](https://github.com/ProctorU/squint/commits?author=dwilkins "Documentation") [💡](#example-dwilkins "Examples") [👀](#review-dwilkins "Reviewed Pull Requests") [⚠️](https://github.com/ProctorU/squint/commits?author=dwilkins "Tests") | [<img src="https://avatars3.githubusercontent.com/u/19173815?v=3" width="100px;"/><br /><sub>Jay Wright</sub>](https://github.com/TheJayWright)<br />[👀](#review-TheJayWright "Reviewed Pull Requests") |
162
+ | :---: | :---: | :---: | :---: | :---: | :---: | :---: |
163
+ <!-- ALL-CONTRIBUTORS-LIST:END -->
164
+
165
+ This project follows the [all-contributors](https://github.com/kentcdodds/all-contributors) specification. Contributions of any kind welcome!
166
+
167
+ ## Credits
168
+
169
+ Squint is maintained and funded by [ProctorU](https://twitter.com/ProctorUEng).
170
+
171
+ <br>
172
+
173
+ <p align="center">
174
+ <a href="https://twitter.com/ProctorUEng">
175
+ <img src="https://s3-us-west-2.amazonaws.com/dev-team-resources/procki-eyes.svg" width=108 height=72>
176
+ </a>
177
+
178
+ <h3 align="center">
179
+ <a href="https://twitter.com/ProctorUEng">ProctorU Engineering & Design</a>
180
+ </h3>
181
+
182
+ <p align="center">
183
+ A simple online proctoring service that allows you to take exams or certification tests at home.
184
+ </p>
185
+ </p>