From 19a52bee22332c75e4b78a9b26d7585fc43e60fc Mon Sep 17 00:00:00 2001 From: Tony Spataro Date: Wed, 27 Sep 2023 12:49:05 -0700 Subject: [PATCH 1/6] Fix divide-by-0 --- format/mysql/scrub_test.go | 16 +++++++++++++++- format/mysql/state.go | 5 ++++- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/format/mysql/scrub_test.go b/format/mysql/scrub_test.go index 4a20b05..71710fe 100644 --- a/format/mysql/scrub_test.go +++ b/format/mysql/scrub_test.go @@ -11,6 +11,8 @@ import ( "github.com/xeger/pipeclean/scrubbing" ) +var nullPolicy = &scrubbing.Policy{} + func read(t *testing.T, name string) string { data, err := ioutil.ReadFile("testdata/" + name) if err != nil { @@ -20,6 +22,10 @@ func read(t *testing.T, name string) string { } func scrub(ctx *mysql.Context, input string) string { + return scrubPolicy(ctx, input, scrubbing.DefaultPolicy()) +} + +func scrubPolicy(ctx *mysql.Context, input string, policy *scrubbing.Policy) string { reader := bufio.NewReader(bytes.NewBufferString(input)) in := make(chan string) @@ -27,7 +33,7 @@ func scrub(ctx *mysql.Context, input string) string { output := bytes.NewBuffer(make([]byte, 0, len(input))) writer := bufio.NewWriter(output) - scrubber := scrubbing.NewScrubber("", false, scrubbing.DefaultPolicy(), nil) + scrubber := scrubbing.NewScrubber("", false, policy, nil) go mysql.ScrubChan(ctx, scrubber, in, out) for { @@ -96,3 +102,11 @@ func TestInsertPositional(t *testing.T) { t.Errorf("UNLOCK TABLES statement is missing") } } + +func TestInsertPositionalNoScan(t *testing.T) { + input := read(t, "insert-positional.sql") + + ctx := mysql.NewContext() + // output may not be useful, but it shouldn't crash if there are no column names to work with! + scrub(ctx, input) +} diff --git a/format/mysql/state.go b/format/mysql/state.go index d0423fd..b1ad09b 100644 --- a/format/mysql/state.go +++ b/format/mysql/state.go @@ -17,7 +17,10 @@ type insertState struct { func (is insertState) Names() []string { names := make([]string, 0, 3) if len(is.tableName) > 0 { - colIdx := is.valueIndex % len(is.columnNames) + colIdx := is.valueIndex + if len(is.columnNames) > 0 { + colIdx = colIdx % len(is.columnNames) + } if len(is.columnNames) > 0 { colName := is.columnNames[colIdx] names = append(names, colName) From 35a48cbafa4a8553901ae558cd8710a065b03e49 Mon Sep 17 00:00:00 2001 From: Tony Spataro Date: Wed, 27 Sep 2023 15:36:03 -0700 Subject: [PATCH 2/6] Use a less confusing name for type-switch locals --- format/mysql/schema_info_visitor.go | 6 +++--- format/mysql/scrub_visitor.go | 10 +++++----- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/format/mysql/schema_info_visitor.go b/format/mysql/schema_info_visitor.go index 6f22a01..5e75a9b 100644 --- a/format/mysql/schema_info_visitor.go +++ b/format/mysql/schema_info_visitor.go @@ -19,9 +19,9 @@ func (v *schemaInfoVisitor) ScanStatement(stmt ast.StmtNode) { } func (v *schemaInfoVisitor) Enter(in ast.Node) (ast.Node, bool) { - switch st := in.(type) { + switch typed := in.(type) { case *ast.TableName: - v.tableName = st.Name.L + v.tableName = typed.Name.L if v.info.TableColumns[v.tableName] == nil { v.info.TableColumns[v.tableName] = make([]string, 0, 32) } @@ -29,7 +29,7 @@ func (v *schemaInfoVisitor) Enter(in ast.Node) (ast.Node, bool) { v.columnDef = true case *ast.ColumnName: if v.columnDef { - v.info.TableColumns[v.tableName] = append(v.info.TableColumns[v.tableName], st.Name.L) + v.info.TableColumns[v.tableName] = append(v.info.TableColumns[v.tableName], typed.Name.L) } } return in, false diff --git a/format/mysql/scrub_visitor.go b/format/mysql/scrub_visitor.go index 3b91f95..1508041 100644 --- a/format/mysql/scrub_visitor.go +++ b/format/mysql/scrub_visitor.go @@ -35,15 +35,15 @@ func (v *scrubVisitor) ScrubStatement(stmt ast.StmtNode) (ast.StmtNode, bool) { } func (v *scrubVisitor) Enter(in ast.Node) (ast.Node, bool) { - switch st := in.(type) { + switch typed := in.(type) { case *ast.TableName: if v.insert != nil { - v.insert.tableName = st.Name.L + v.insert.tableName = typed.Name.L } case *ast.ColumnName: // insert column names present in SQL source; accumulate them if v.insert != nil { - v.insert.columnNames = append(v.insert.columnNames, st.Name.L) + v.insert.columnNames = append(v.insert.columnNames, typed.Name.L) } case *test_driver.ValueExpr: if v.insert != nil { @@ -54,10 +54,10 @@ func (v *scrubVisitor) Enter(in ast.Node) (ast.Node, bool) { defer func() { v.insert.valueIndex++ }() - switch st.Kind() { + switch typed.Kind() { case test_driver.KindString: datum := test_driver.Datum{} - s := st.Datum.GetString() + s := typed.Datum.GetString() names := v.insert.Names() if v.scrubber.EraseString(s, names) { datum.SetNull() From cf4f407beb5b7ba20cb8987b4a51b3c6e32c1aa7 Mon Sep 17 00:00:00 2001 From: Tony Spataro Date: Wed, 27 Sep 2023 17:07:02 -0700 Subject: [PATCH 3/6] Prepare for more stateful insert --- format/mysql/extract_visitor.go | 23 ++++++++++------------- format/mysql/learn_visitor.go | 23 ++++++++++------------- format/mysql/scrub_visitor.go | 11 ++++------- format/mysql/state.go | 26 +++++++++++++++++++++++--- 4 files changed, 47 insertions(+), 36 deletions(-) diff --git a/format/mysql/extract_visitor.go b/format/mysql/extract_visitor.go index cfe9ec5..d82fd8b 100644 --- a/format/mysql/extract_visitor.go +++ b/format/mysql/extract_visitor.go @@ -14,9 +14,9 @@ type extractVisitor struct { // ExtractStatement pulls interesting field values from INSERT statements. func (v *extractVisitor) ExtractStatement(stmt ast.StmtNode) []string { - switch stmt.(type) { + switch typed := stmt.(type) { case *ast.InsertStmt: - v.insert = &insertState{} + v.insert = newInsertState(typed) v.values = []string{} stmt.Accept(v) v.insert = nil @@ -25,31 +25,28 @@ func (v *extractVisitor) ExtractStatement(stmt ast.StmtNode) []string { } func (v *extractVisitor) Enter(in ast.Node) (ast.Node, bool) { - switch st := in.(type) { + switch typed := in.(type) { case *ast.TableName: if v.insert != nil { - v.insert.tableName = st.Name.L + v.insert.tableName = typed.Name.L } case *ast.ColumnName: // insert column names present in SQL source; accumulate them if v.insert != nil { - v.insert.columnNames = append(v.insert.columnNames, st.Name.L) + v.insert.columnNames = append(v.insert.columnNames, typed.Name.L) } case *test_driver.ValueExpr: if v.insert != nil { - // column names omitted from SQL source; infer from table schema - if v.insert.valueIndex == 0 && len(v.insert.columnNames) == 0 { - v.insert.columnNames = v.ctx.TableColumns[v.insert.tableName] - } + v.insert.ObserveContext(v.ctx) defer func() { - v.insert.valueIndex++ + v.insert.Advance() }() - switch st.Kind() { + switch typed.Kind() { case test_driver.KindString: if v.MatchFieldName(v.insert.Names()) { - v.values = append(v.values, st.Datum.GetString()) + v.values = append(v.values, typed.Datum.GetString()) } - return st, true + return typed, true } } } diff --git a/format/mysql/learn_visitor.go b/format/mysql/learn_visitor.go index c012fbf..003b6db 100644 --- a/format/mysql/learn_visitor.go +++ b/format/mysql/learn_visitor.go @@ -16,45 +16,42 @@ type learnVisitor struct { // LearnStatement trains models based on values in a SQL insert AST. func (v *learnVisitor) LearnStatement(stmt ast.StmtNode) { - switch stmt.(type) { + switch typed := stmt.(type) { case *ast.InsertStmt: - v.insert = &insertState{} + v.insert = newInsertState(typed) stmt.Accept(v) v.insert = nil } } func (v *learnVisitor) Enter(in ast.Node) (ast.Node, bool) { - switch st := in.(type) { + switch typed := in.(type) { case *ast.TableName: if v.insert != nil { - v.insert.tableName = st.Name.L + v.insert.tableName = typed.Name.L } case *ast.ColumnName: // insert column names present in SQL source; accumulate them if v.insert != nil { - v.insert.columnNames = append(v.insert.columnNames, st.Name.L) + v.insert.columnNames = append(v.insert.columnNames, typed.Name.L) } case *test_driver.ValueExpr: if v.insert != nil { - // column names omitted from SQL source; infer from table schema - if v.insert.valueIndex == 0 && len(v.insert.columnNames) == 0 { - v.insert.columnNames = v.ctx.TableColumns[v.insert.tableName] - } + v.insert.ObserveContext(v.ctx) defer func() { - v.insert.valueIndex++ + v.insert.Advance() }() - switch st.Kind() { + switch typed.Kind() { case test_driver.KindString: disposition, _ := v.policy.MatchFieldName(v.insert.Names()) switch disposition.Action() { case "generate": model := v.models[disposition.Parameter()] if model != nil { - model.Train(st.Datum.GetString()) + model.Train(typed.Datum.GetString()) } } - return st, true + return typed, true } } } diff --git a/format/mysql/scrub_visitor.go b/format/mysql/scrub_visitor.go index 1508041..aa18e24 100644 --- a/format/mysql/scrub_visitor.go +++ b/format/mysql/scrub_visitor.go @@ -16,10 +16,10 @@ type scrubVisitor struct { // May modify the AST in-place (and return it), or may return a derived AST. // Returns nil if the entire statement should be omitted from output. func (v *scrubVisitor) ScrubStatement(stmt ast.StmtNode) (ast.StmtNode, bool) { - switch stmt.(type) { + switch typed := stmt.(type) { case *ast.InsertStmt: if doInserts { - v.insert = &insertState{} + v.insert = newInsertState(typed) stmt.Accept(v) v.insert = nil return stmt, true @@ -47,12 +47,9 @@ func (v *scrubVisitor) Enter(in ast.Node) (ast.Node, bool) { } case *test_driver.ValueExpr: if v.insert != nil { - // column names omitted from SQL source; infer from table schema - if v.insert.valueIndex == 0 && len(v.insert.columnNames) == 0 { - v.insert.columnNames = v.ctx.TableColumns[v.insert.tableName] - } + v.insert.ObserveContext(v.ctx) defer func() { - v.insert.valueIndex++ + v.insert.Advance() }() switch typed.Kind() { case test_driver.KindString: diff --git a/format/mysql/state.go b/format/mysql/state.go index b1ad09b..a40c723 100644 --- a/format/mysql/state.go +++ b/format/mysql/state.go @@ -1,6 +1,10 @@ package mysql -import "fmt" +import ( + "fmt" + + "github.com/pingcap/tidb/parser/ast" +) type insertState struct { // Name of the table being inserted into. @@ -11,10 +15,19 @@ type insertState struct { valueIndex int } -// Names returns a list of column names to which the Next ValueExpr will apply. +func newInsertState(stmt *ast.InsertStmt) *insertState { + return &insertState{} +} + +// Advance increments the column-value index so that Names() remains accurate. +func (is *insertState) Advance() { + is.valueIndex += 1 +} + +// Names returns a list of column names to which the next ValueExpr will apply. // The list contains 0-3 elements depending on the completeness of the schema // information provided in context. -func (is insertState) Names() []string { +func (is *insertState) Names() []string { names := make([]string, 0, 3) if len(is.tableName) > 0 { colIdx := is.valueIndex @@ -31,3 +44,10 @@ func (is insertState) Names() []string { return names } + +// If column names were omitted from the SQL INSERT statement, infer them from the previously-scanned table schema. +func (is *insertState) ObserveContext(ctx *Context) { + if is.valueIndex == 0 && len(is.columnNames) == 0 { + is.columnNames = ctx.TableColumns[is.tableName] + } +} From 074acac2dcdfda8ea063970443a1b51b0fdf1954 Mon Sep 17 00:00:00 2001 From: Tony Spataro Date: Wed, 27 Sep 2023 21:30:35 -0700 Subject: [PATCH 4/6] Accurately track row & column name for NxM inserts. --- format/mysql/state.go | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/format/mysql/state.go b/format/mysql/state.go index a40c723..fce3202 100644 --- a/format/mysql/state.go +++ b/format/mysql/state.go @@ -11,12 +11,23 @@ type insertState struct { tableName string // List of column names (explicitly specified in current statement, or inferred from table schema). columnNames []string + // Value tuple sizes of the current statement. + rowLength int // Number of ValueExpr seen so far across all rows of current statement. valueIndex int } func newInsertState(stmt *ast.InsertStmt) *insertState { - return &insertState{} + rowLength := 0 + for _, list := range stmt.Lists { + if rowLength == 0 { + rowLength = len(list) + } else if len(list) != rowLength { + // TODO: handle this case by storing an array of row-tuple lengths & iterating through it + panic(fmt.Sprintf("inconsistent INSERT row lengths: %d prior vs %d next", rowLength, len(list))) + } + } + return &insertState{rowLength: rowLength} } // Advance increments the column-value index so that Names() remains accurate. @@ -28,12 +39,12 @@ func (is *insertState) Advance() { // The list contains 0-3 elements depending on the completeness of the schema // information provided in context. func (is *insertState) Names() []string { + colIdx := is.valueIndex + if is.rowLength > 0 { + colIdx = colIdx % is.rowLength + } names := make([]string, 0, 3) if len(is.tableName) > 0 { - colIdx := is.valueIndex - if len(is.columnNames) > 0 { - colIdx = colIdx % len(is.columnNames) - } if len(is.columnNames) > 0 { colName := is.columnNames[colIdx] names = append(names, colName) From c10de0dbb196b600696dfb00afdcfefcb2b1ee8f Mon Sep 17 00:00:00 2001 From: Tony Spataro Date: Fri, 29 Sep 2023 08:35:28 -0700 Subject: [PATCH 5/6] Fix error message --- cmd/extract.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/extract.go b/cmd/extract.go index 9781713..7649698 100644 --- a/cmd/extract.go +++ b/cmd/extract.go @@ -25,7 +25,7 @@ func init() { func extract(cmd *cobra.Command, args []string) { if len(args) != 1 { - ui.Fatalf("Must pass exactly one directory for model storage") + ui.Fatalf("Must pass exactly one field name to extract") ui.Exit('-') } From 417c2a61cfd914b860001f47166148a781b24bdc Mon Sep 17 00:00:00 2001 From: Tony Spataro Date: Fri, 29 Sep 2023 15:31:27 -0700 Subject: [PATCH 6/6] Positively identify JSON/YAML before deep scrubbing it --- format/mysql/scrub_test.go | 4 +-- scrubbing/scrubber.go | 39 ++++++++++++++++++++--------- scrubbing/scrubber_test.go | 33 ++++++++++++++++++++++++ scrubbing/testdata/quill-delta.json | 1 + 4 files changed, 63 insertions(+), 14 deletions(-) create mode 100644 scrubbing/testdata/quill-delta.json diff --git a/format/mysql/scrub_test.go b/format/mysql/scrub_test.go index 71710fe..427b486 100644 --- a/format/mysql/scrub_test.go +++ b/format/mysql/scrub_test.go @@ -3,7 +3,7 @@ package mysql_test import ( "bufio" "bytes" - "io/ioutil" + "os" "strings" "testing" @@ -14,7 +14,7 @@ import ( var nullPolicy = &scrubbing.Policy{} func read(t *testing.T, name string) string { - data, err := ioutil.ReadFile("testdata/" + name) + data, err := os.ReadFile("testdata/" + name) if err != nil { t.Fatalf("Failed to read test file %s: %s", name, err) } diff --git a/scrubbing/scrubber.go b/scrubbing/scrubber.go index 7bd4f52..2aac2bc 100644 --- a/scrubbing/scrubber.go +++ b/scrubbing/scrubber.go @@ -16,8 +16,21 @@ import ( "gopkg.in/yaml.v3" ) +// ReShortExtension identifies filename-like extensions at the end of strings. var reShortExtension = regexp.MustCompile(`[.][a-z]{2,5}$`) +func isJsonData(s string) bool { + if len(s) >= 2 { + f, l := s[0], s[len(s)-1] + return (f == '{' && l == '}') || (f == '[' && l == ']') + } + return false +} + +func isYamlData(s string) bool { + return strings.Index(s, "---\n") == 0 +} + type Scrubber struct { maskAll bool models map[string]nlp.Model @@ -146,23 +159,25 @@ func (sc *Scrubber) ScrubString(s string, names []string) string { if !sc.shallow { var data any - if err := json.Unmarshal([]byte(s), &data); err == nil { - scrubbed, err := json.Marshal(sc.ScrubData(data, nil)) - if err != nil { - ui.Fatal(err) - } - return string(scrubbed) - } - - if err := yaml.Unmarshal([]byte(s), &data); err == nil { - switch v := data.(type) { - case []any, map[string]any: - scrubbed, err := yaml.Marshal(sc.ScrubData(v, nil)) + if isJsonData(s) { + if err := json.Unmarshal([]byte(s), &data); err == nil { + scrubbed, err := json.Marshal(sc.ScrubData(data, nil)) if err != nil { ui.Fatal(err) } return string(scrubbed) } + } else if isYamlData(s) { + if err := yaml.Unmarshal([]byte(s), &data); err == nil { + switch v := data.(type) { + case []any, map[string]any: + scrubbed, err := yaml.Marshal(sc.ScrubData(v, nil)) + if err != nil { + ui.Fatal(err) + } + return string(scrubbed) + } + } } // Empty serialized Ruby YAML hashes. diff --git a/scrubbing/scrubber_test.go b/scrubbing/scrubber_test.go index 57948a6..fc493cd 100644 --- a/scrubbing/scrubber_test.go +++ b/scrubbing/scrubber_test.go @@ -1,7 +1,10 @@ package scrubbing_test import ( + "encoding/json" "fmt" + "os" + "reflect" "regexp" "testing" @@ -11,6 +14,24 @@ import ( const salt = "github.com/xeger/pipeclean/scrubbing" +var nullPolicy = &scrubbing.Policy{} + +func read(t *testing.T, name string) string { + data, err := os.ReadFile("testdata/" + name) + if err != nil { + t.Fatalf("Failed to read test file %s: %s", name, err) + } + return string(data) +} + +func unmarshalJSON(t *testing.T, s string) any { + var data any + if err := json.Unmarshal([]byte(s), &data); err != nil { + t.Fatalf(`invalid fixture: %s`, err) + } + return data +} + func scrub(s, field string) string { return scrubbing.NewScrubber(salt, false, scrubbing.DefaultPolicy(), nil).ScrubString(s, []string{field}) } @@ -148,3 +169,15 @@ func TestDispositionReplace(t *testing.T) { } } } + +func TestDataPreserveJSON(t *testing.T) { + stringBefore := read(t, "quill-delta.json") + dataBefore := unmarshalJSON(t, stringBefore) + + stringAfter := scrubWithPolicy(stringBefore, "irrelevant", nullPolicy, nil) + dataAfter := unmarshalJSON(t, stringAfter) + + if !reflect.DeepEqual(dataBefore, dataAfter) { + t.Errorf("scrubbed JSON does not match original under null policy!") + } +} diff --git a/scrubbing/testdata/quill-delta.json b/scrubbing/testdata/quill-delta.json new file mode 100644 index 0000000..5eddfda --- /dev/null +++ b/scrubbing/testdata/quill-delta.json @@ -0,0 +1 @@ +[{"attributes":{"height":"389.15154613874347","width":"583"},"insert":{"image":"cid:llg2ul4y"}},{"attributes":{"align":"justify"},"insert":"\n"},{"attributes":{"size":"x-large","bold":true},"insert":"Awesome Place"},{"attributes":{"align":"justify"},"insert":"\n"},{"attributes":{"bold":true},"insert":"Long Beach, CA | Retail"},{"attributes":{"align":"justify"},"insert":"\n"},{"insert":{"image":"cid:lln0vow9"}},{"attributes":{"align":"justify"},"insert":"\n"},{"attributes":{"bold":true},"insert":"Offered By:"},{"attributes":{"align":"justify"},"insert":"\n"},{"attributes":{"size":"x-large","bold":true},"insert":"Waterfall Partners"},{"attributes":{"align":"justify"},"insert":"\n\n"},{"attributes":{"bold":true},"insert":"Investment Strategy: "},{"insert":"Value-Add"},{"attributes":{"align":"justify"},"insert":"\n\n"},{"attributes":{"bold":true},"insert":"Investment Type: "},{"insert":"Equity"},{"attributes":{"align":"justify"},"insert":"\n\n"},{"attributes":{"bold":true},"insert":"Estimated First Distribution: "},{"insert":"10/2023"},{"attributes":{"align":"justify"},"insert":"\n"},{"insert":"\n"},{"insert":{"image":"cid:lln11nep"}},{"insert":"\n"},{"attributes":{"color":"#303030","font":"Verdana, sans-serif"},"insert":" "},{"attributes":{"height":"32.616875","width":"138"},"insert":{"image":"cid:ll8ertu7"}},{"insert":"\n"},{"insert":{"image":"cid:lle31ke3"}},{"insert":"\n"},{"attributes":{"font":"Nunito Sans","size":"13.5pt"},"insert":"Waterfall Partners, the Sponsor, is proud to offer Friends & Business Partners an equity investment opportunity, through the sale of Units, into Awesome Place, a 9,710 SF, fully leased, cash flowing commercial retail property located in the South Long Beach Submarket of Long Beach, CA. Per CoStar, Long Beach is listed as one of the top 5 fastest growing retail markets in the US & #1 Best Place to Live in California according to Forbes and Redfin. The Sponsor purchased Awesome Place in 2018 at a value of $2 Million Dollars. Under the Sponsor's management, the Property's value has increased by over 175% in less than 5 years. In 2022, the Sponsor refinanced the property and secured a 5 year, 4.25% fixed interest rate loan which would be generally difficult to find in today’s market. This advantage, coupled with the offering cap rate of 6.3% positions investors going in with positive leverage. Unlocking further potential, Waterfall Partners seeks to modestly increase rents through the execution of new leases and renewals. We believe that the asset's prime location, high cash flow, tenant strength, asset quality, and the high market demand make it a lucrative and reliable investment. The Sponsor intends to exit the asset in Year 3 by sale or refinance of the property. At time of exit, investors will receive 100% return of their initial capital contribution, after which any further returns will be a return on investment."},{"insert":"\n\n "},{"attributes":{"height":"81.03706395348837","width":"93"},"insert":{"image":"cid:llgtgjnx"}},{"attributes":{"align":"center"},"insert":"\n"},{"attributes":{"height":"4.673716012084592","width":"1547","color":"#303030","font":"Verdana, sans-serif"},"insert":{"image":"cid:lkt0jk5g"}},{"insert":"\n"},{"attributes":{"font":"Nunito Sans","size":"13.5pt"},"insert":" "},{"insert":"\n"},{"attributes":{"color":"#303030","font":"Nunito Sans","size":"13.5pt","bold":true},"insert":"Attractive Going-In Basis"},{"attributes":{"italic":true,"bold":true},"insert":". "},{"attributes":{"font":"Nunito Sans","size":"18px"},"insert":"At a 6.3% cap rate, the"},{"attributes":{"font":"Nunito Sans","size":"13.5pt"},"insert":" offering price represents a 20% discount to comparable sales within the submarket"},{"insert":"\n\n"},{"attributes":{"color":"#303030","font":"Nunito Sans","size":"13.5pt","bold":true},"insert":"Rising Interest Rate Protection. "},{"attributes":{"color":"#303030","font":"Nunito Sans","size":"13.5pt"},"insert":"In January of 2022"},{"attributes":{"color":"#303030","font":"Nunito Sans","size":"13.5pt","bold":true},"insert":","},{"attributes":{"color":"#303030","font":"Nunito Sans","size":"13.5pt"},"insert":" the sponsor secured a 4.25%"},{"attributes":{"color":"#303030","font":"Nunito Sans","size":"1rem"},"insert":" "},{"attributes":{"color":"#303030","font":"Nunito Sans","size":"13.5pt"},"insert":"fixed interest rate bank loan. The loan rate of 4.25% offers investors going-in positive leverage. "},{"insert":"\n\n"},{"insert":{"image":"cid:ljn8ekk7"}},{"attributes":{"height":"80.10444078947368","width":"79"},"insert":{"image":"cid:ljn8ez7f"}},{"attributes":{"align":"center"},"insert":"\n"},{"attributes":{"height":"4.746223564954683","width":"1571","color":"#303030","font":"Verdana, sans-serif"},"insert":{"image":"cid:lkt0jk5g"}},{"insert":"\n\n"},{"attributes":{"size":"18px","font":"Nunito Sans","bold":true},"insert":"Prime Location."},{"attributes":{"size":"18px","font":"Nunito Sans"},"insert":" The MSA ranked #1 Best Place to Live in California & 3rd Most Popular City to Move To according to Forbes and Redfin"},{"insert":"\n\n"},{"attributes":{"color":"#303030","font":"Nunito Sans","size":"13.5pt"},"insert":"Awesome Place is located along the east side of South MacDill Avenue in a mature area of South Long Beach that has undergone revitalization over a period of years and now has become a fashionable area with historic homes, numerous restaurants, clothing boutiques as well as strip and freestanding retail establishments, self-storage, and service-related businesses."},{"insert":"\n\n"},{"attributes":{"font":"Nunito Sans","size":"13.5pt","color":"#303030"},"insert":"Residential — Adequate mix of established single-family communities and multifamily uses in the form of apartments and for sale townhomes."},{"attributes":{"list":"bullet"},"insert":"\n"},{"attributes":{"color":"#303030","font":"Nunito Sans","size":"13.5pt"},"insert":" "},{"insert":"\n"},{"attributes":{"color":"#303030","font":"Nunito Sans","size":"13.5pt"},"insert":"Retail - Most of the retail businesses located in the market area are concentrated along South Hollister, Orange St, and Pine St. The subject is about two miles southwest of Hyde Park Village, which is an outdoor center offering shopping, dining, and other entertainment options. The Britton Plaza shopping center is located approximately one mile to the southwest and features many good stores as anchors. Redevelopment continues to drive revitalization in the market area. Adjacent to the subject's northwest, a former bicycle repair shop comprised on nearly 9,000 SF is being renovated into a new multi-tenant, retail property."},{"attributes":{"list":"bullet"},"insert":"\n"},{"attributes":{"color":"#303030","font":"Nunito Sans","size":"13.5pt"},"insert":" "},{"insert":"\n"},{"attributes":{"color":"#303030","font":"Nunito Sans","size":"13.5pt"},"insert":"Employment centers — The market area has numerous commercial businesses. However, the major employment centers are located in Downtown Long Beach, which is approximately four miles to the northeast of the subject, and the Westshore Business District approximately four miles to the northwest."},{"attributes":{"list":"bullet"},"insert":"\n"},{"attributes":{"color":"#303030","font":"Nunito Sans","size":"13.5pt"},"insert":" "},{"insert":"\n"},{"attributes":{"color":"#303030","font":"Nunito Sans","size":"13.5pt"},"insert":"Health Care — Major hospitals in the market area include Cottage and Cottage, both to the northeast of the subject. Also proximate to the subject's southeast is Kindred Hospital Bay Area - Long Beach, a smaller facility which specializes in Intensive Care and sewing patients in need of an extended recover period."},{"attributes":{"list":"bullet"},"insert":"\n"},{"attributes":{"color":"#303030","font":"Nunito Sans","size":"13.5pt"},"insert":" "},{"insert":"\n"},{"attributes":{"color":"#303030","font":"Nunito Sans","size":"13.5pt"},"insert":"The neighborhood is fully built-up with commercial concentrations located along the major roadways and residential uses located on the secondary streets. In addition, this area features a large established residential base. The subject area's attributes, including strong demographics, proximity to major roadways and employment centers, and high population density, will result in this area continuing to be one of the stronger sub-markets in the region."},{"insert":"\n\n\n"},{"attributes":{"height":"36.89754746835443","width":"296"},"insert":{"image":"cid:llgx0lqd"}},{"attributes":{"align":"center"},"insert":"\n"},{"attributes":{"height":"4.035912958115183","width":"440"},"insert":{"image":"cid:llgx5yvi"}},{"attributes":{"align":"center"},"insert":"\n"},{"attributes":{"height":"153.7789948453608","width":"315"},"insert":{"image":"cid:lmw8pqjx"}},{"attributes":{"align":"center"},"insert":"\n"},{"insert":{"image":"cid:llleazhp"}},{"insert":"\n"},{"attributes":{"height":"32.43830958549223","width":"178"},"insert":{"image":"cid:lllf6vre"}},{"insert":"\n"},{"insert":{"image":"cid:lle35btk"}},{"attributes":{"align":"justify"},"insert":"\n"},{"attributes":{"height":"63.61256544502618","width":"270"},"insert":{"image":"cid:llg44115"}},{"insert":"\n"},{"attributes":{"size":"13.5pt","font":"Nunito Sans"},"insert":"Waterfall Partners is a growing Real Estate Investment Firm with offices in Long Beach & Los Angeles. We acquire and manage real estate properties with a focus on both near-term income generation and long-term value creation. Abigail President, Founder & CEO has more than 10 years experience in the commercial real estate segment as an active investor and manager. Our objective is to generate attractive, long term risk-adjusted returns for the benefits of our clients. We earn asset management income for doing so and ensure strong alignment of interests with our clients by investing alongside them."},{"insert":"\n\n"},{"insert":{"image":"cid:lm5ars41"}},{"insert":"\n"},{"attributes":{"height":"34.341931216931215","width":"200"},"insert":{"image":"cid:lm5cn34v"}},{"insert":{"image":"cid:lm5ars41"}},{"insert":"\n"},{"attributes":{"color":"#202020","font":"Nunito Sans","size":"13.5pt"},"insert":"Waterfall Partners (the “Sponsor”) along with Abigail President (the “Affiliate”) are proud to offer an Equity Investment opportunity into Awesome Place (the “Property”). The offering is expected to close in September of 2023. The Property, built in 1947, is a 9,710 square foot, 100% leased Class B commercial retail property in South Long Beach, CA. Per CoStar, Long Beach is listed as one of the top 5 fastest growing retail markets in the US."},{"insert":"\n"},{"attributes":{"color":"#303030","font":"Segoe UI, sans-serif","size":"13.5pt"},"insert":" "},{"insert":"\n"},{"attributes":{"color":"#202020","font":"Nunito Sans","size":"13.5pt"},"insert":"The Sponsor believes that the asset's prime location, high cash flow, tenant strength, asset quality, and the high market demand make it a lucrative and reliable investment."},{"insert":{"image":"cid:lm6m68pw"}},{"insert":"\n"},{"attributes":{"bold":true},"insert":"Cash Flow Projections"},{"insert":"\n"},{"insert":{"image":"cid:lm6m68pw"}},{"insert":"\n"},{"insert":{"image":"cid:lmw81jgs"}},{"insert":"\n"},{"insert":{"image":"cid:lm6m68pw"}},{"insert":"\n"},{"attributes":{"bold":true},"insert":"Exit Plan"},{"insert":"\n"},{"insert":{"image":"cid:lm6mdh8q"}},{"insert":"\n"},{"attributes":{"color":"#202020","font":"Nunito Sans","size":"13.5pt"},"insert":"The Sponsor intends to exit the asset in Year 3 by sale at a 5% Cap Rate or refinance of the property."},{"insert":"\n"},{"attributes":{"height":"175.87769784172662","width":"562"},"insert":{"image":"cid:lmw8c05a"}},{"insert":"\n"},{"insert":{"image":"cid:lm5ars41"}},{"insert":"\n"},{"attributes":{"bold":true},"insert":"Investor Level - Hypothetical $100,000 Investment"},{"insert":"\n"},{"insert":{"image":"cid:lln0vow9"}},{"insert":"\n"},{"attributes":{"height":"474.4239583333333","width":"306"},"insert":{"image":"cid:llv596rj"}},{"insert":"\n"},{"insert":{"image":"cid:lm5ars41"}},{"insert":"\n"},{"attributes":{"height":"26.733678343949045","width":"148"},"insert":{"image":"cid:lm5c2bvz"}},{"insert":"\n"},{"insert":{"image":"cid:llle5ihh"}},{"insert":"\n"},{"attributes":{"color":"#303030","font":"Nunito Sans","size":"13.5pt"},"insert":"Awesome Place, built in 1947 is a Class B Commercial Retail Property that is located in South Long Beach, CA. The Property sits on 0.46 Acres with 35 Parking Spaces and consists of six NNN lease tenants. The Property is located along the east side of South MacDill Avenue in a mature area of South Long Beach that has undergone revitalization over a period of years and now has become a fashionable area with historic homes, numerous restaurants, clothing boutiques as well as strip and freestanding retail establishments, self-storage, and service-related businesses."},{"insert":"\n\n"},{"attributes":{"color":"#303030","font":"Nunito Sans","size":"13.5pt"},"insert":"Per CoStar, Retail asking rents continue to rise here, up 8.8% year over year, outpacing the overall Long Beach's annual growth rate of 7.9%"},{"attributes":{"color":"#303030","size":"13.5pt"},"insert":""},{"attributes":{"color":"#303030","size":"13.5pt","font":"Nunito Sans"},"insert":". The bulk of asking rent growth has occurred in Malls and Neighborhood Centers, up more than 9.5% year over year. Looking ahead, asking rent growth is forecasted to remain positive for the foreseeable future. Retail investment soared in the first quarter of 2023 with over $40 million in total sales volume, driving the trailing 12-month total to $81.7 million"},{"attributes":{"color":"#303030","size":"13.5pt"},"insert":""},{"attributes":{"color":"#303030","size":"13.5pt","font":"Nunito Sans"},"insert":". Retail investors can expect to pay a premium here with an average per SF of $330"},{"attributes":{"color":"#303030","size":"13.5pt"},"insert":""},{"attributes":{"color":"#303030","size":"13.5pt","font":"Nunito Sans"},"insert":", 25% higher than the overall Long Beach average."},{"insert":"\n"},{"insert":{"image":"cid:lm5d76bw"}},{"insert":"\n"},{"attributes":{"height":"27.374999999999996","width":"156"},"insert":{"image":"cid:llleamfs"}},{"insert":"\n"},{"insert":{"image":"cid:llleazhp"}},{"insert":"\n"},{"attributes":{"bold":true},"insert":"Capital Stack"},{"insert":"\n"},{"insert":{"image":"cid:llleazhp"}},{"insert":"\n"},{"attributes":{"bold":true},"insert":"Property:"},{"insert":"\n"},{"attributes":{"height":"361.14676616915426","width":"656"},"insert":{"image":"cid:lmxy9ddw"}},{"insert":"\n"},{"insert":{"image":"cid:llleazhp"}},{"insert":"\n"},{"attributes":{"bold":true},"insert":"Senior Loan"},{"insert":"\n"},{"insert":{"image":"cid:llleazhp"}},{"insert":"\nLender: Bank OZK\n\nInterest Type: Fixed\n\nInterest Rate: 4.25%\n\nTerm: 5 Years\n\nMaturity Date: 1/27/2027\n"},{"insert":{"image":"cid:lm5ars41"}},{"insert":"\n"},{"attributes":{"bold":true},"insert":"Distributions"},{"insert":{"image":"cid:llleazhp"}},{"insert":"\n"},{"attributes":{"color":"#303030","font":"Nunito Sans","size":"13.5pt"},"insert":"Waterfall Partners intends to make distributions to Investors from Waterfall Partners (the \"Company\") on a monthly basis out of funds received from ownership interest in Waterfall Awesome LLC (the \"Property Owner\")."},{"insert":"\n\n"},{"attributes":{"color":"#303030","font":"Nunito Sans","size":"13.5pt"},"insert":"Waterfall Partners intends to make distributions as follows:"},{"insert":"\n\n"},{"attributes":{"color":"#303030","font":"Nunito Sans","size":"13.5pt","bold":true},"insert":"Allocations and Distributions of Net Cash Flow from Operations"},{"insert":"\n\n"},{"attributes":{"font":"Nunito Sans","size":"13.5pt"},"insert":"1."},{"attributes":{"font":"Times New Roman","size":"7pt"},"insert":" "},{"attributes":{"font":"Nunito Sans","size":"13.5pt"},"insert":"To the Class A Investors, A 10% preferred return calculated on capital invested and non-compounding."},{"insert":"\n"},{"attributes":{"color":"#303030","font":"Nunito Sans","size":"13.5pt"},"insert":" "},{"insert":"\n"},{"attributes":{"font":"Nunito Sans","size":"13.5pt"},"insert":"2."},{"attributes":{"font":"Times New Roman","size":"7pt"},"insert":" "},{"attributes":{"font":"Nunito Sans","size":"13.5pt"},"insert":"80% / 20% (80% to Class A / 20% to Class B) of any Distributable Cash."},{"insert":"\n"},{"attributes":{"color":"#303030","size":"13.5pt","bold":true},"insert":""},{"insert":"\n"},{"attributes":{"color":"#303030","font":"Nunito Sans","size":"13.5pt","bold":true},"insert":"Net Proceeds Upon Class A Members Exit from the Company"},{"insert":"\n\n"},{"attributes":{"color":"#303030","font":"Nunito Sans","size":"13.5pt"},"insert":"Upon the Company’s exercise of the Call Option, Class A Members will receive their buy-out payment and no longer be a member of the Company."},{"insert":"\n\n"},{"attributes":{"color":"#303030","font":"Nunito Sans","size":"13.5pt","bold":true},"insert":"Net Proceeds from Sale or Refinance of the Property"},{"insert":"\n\n"},{"attributes":{"color":"#303030","font":"Nunito Sans","size":"13.5pt"},"insert":"In the event that the Company does not exercise the Call Option, then the Net Cash Flow to the Company as a result of any sale or refinance of the Property, after payment of debts, fees and establishment of necessary reserves, will be allocated as follows:"},{"insert":"\n\n"},{"attributes":{"italic":true,"color":"#303030","font":"Nunito Sans","size":"13.5pt","bold":true},"insert":"Refinance of Property:"},{"insert":"\n\n"},{"attributes":{"font":"Nunito Sans","size":"13.5pt"},"insert":"1."},{"attributes":{"font":"Times New Roman","size":"7pt"},"insert":" "},{"attributes":{"font":"Nunito Sans","size":"13.5pt"},"insert":"First, to the Class A Members until they have received a return of one hundred percent (100%) of their Capital Contributions; then"},{"insert":"\n"},{"attributes":{"color":"#303030","font":"Nunito Sans","size":"13.5pt"},"insert":" "},{"insert":"\n"},{"attributes":{"font":"Nunito Sans","size":"13.5pt"},"insert":"2."},{"attributes":{"font":"Times New Roman","size":"7pt"},"insert":" "},{"attributes":{"font":"Nunito Sans","size":"13.5pt"},"insert":"Class A Members shall receive a total of eighty percent (80%) of the Distributable Cash, and"},{"insert":"\n"},{"attributes":{"color":"#303030","font":"Nunito Sans","size":"13.5pt"},"insert":" "},{"insert":"\n"},{"attributes":{"font":"Nunito Sans","size":"13.5pt"},"insert":"3."},{"attributes":{"font":"Times New Roman","size":"7pt"},"insert":" "},{"attributes":{"font":"Nunito Sans","size":"13.5pt"},"insert":"Class B Members shall receive twenty percent (20%) of any Distributable Cash."},{"insert":"\n"},{"attributes":{"color":"#303030","font":"Nunito Sans","size":"13.5pt"},"insert":" "},{"insert":"\n"},{"attributes":{"color":"#303030","font":"Nunito Sans","size":"13.5pt"},"insert":"Distributable Cash, if any, from a “Capital Transaction” such as a refinance or disposition of a Property, will be distributed as provided below until expended:"},{"insert":"\n"},{"attributes":{"color":"#303030","font":"Nunito Sans","size":"13.5pt"},"insert":" "},{"insert":"\n"},{"attributes":{"italic":true,"color":"#303030","font":"Nunito Sans","size":"13.5pt","bold":true},"insert":"On Disposition of a Property:"},{"insert":"\n\n"},{"attributes":{"font":"Nunito Sans","size":"13.5pt"},"insert":"1."},{"attributes":{"font":"Times New Roman","size":"7pt"},"insert":" "},{"attributes":{"font":"Nunito Sans","size":"13.5pt"},"insert":"First, to the Class A Members until they have received a return of one hundred percent (100%) of their Capital Contributions; then"},{"insert":"\n"},{"attributes":{"color":"#303030","font":"Nunito Sans","size":"13.5pt"},"insert":" "},{"insert":"\n"},{"attributes":{"font":"Nunito Sans","size":"13.5pt"},"insert":"2."},{"attributes":{"font":"Times New Roman","size":"7pt"},"insert":" "},{"attributes":{"font":"Nunito Sans","size":"13.5pt"},"insert":"Class A Members shall receive a total of eighty percent (80%) of the Distributable Cash, and"},{"insert":"\n"},{"attributes":{"color":"#303030","font":"Nunito Sans","size":"13.5pt"},"insert":" "},{"insert":"\n"},{"attributes":{"font":"Nunito Sans","size":"13.5pt"},"insert":"3."},{"attributes":{"font":"Times New Roman","size":"7pt"},"insert":" "},{"attributes":{"font":"Nunito Sans","size":"13.5pt"},"insert":"Class B Members shall receive twenty percent (20%) of any Distributable Cash."},{"insert":"\n"},{"attributes":{"color":"#303030","font":"Nunito Sans","size":"13.5pt"},"insert":" "},{"insert":"\n"},{"attributes":{"color":"#303030","font":"Nunito Sans","size":"13.5pt"},"insert":"For the purposes of Cash Distribution calculations only, all Distributions from Capital Transactions such as a refinance, will be treated as a return of capital until the Class A Members have received one hundred percent (100%) of their initial Capital Contributions, after which any further returns will be a return on investment."},{"insert":"\n\n"},{"attributes":{"color":"#202020","font":"Nunito Sans","size":"13.5pt"},"insert":"Waterfall Partners"},{"attributes":{"color":"red","font":"Nunito Sans","size":"13.5pt"},"insert":" "},{"attributes":{"color":"#202020","font":"Nunito Sans","size":"13.5pt"},"insert":"intends to make distributions to investors after the payment of the company's liabilities (loan payments, operating expenses, and other fees as more specifically set forth in the LLC agreements, in addition to any member loans or returns due on member loan)."},{"insert":"\n\n"},{"attributes":{"color":"#202020","font":"Nunito Sans","size":"13.5pt"},"insert":"Distributions are expected to start immediately following investment and are projected to continue on a Monthly basis thereafter. Distributions are at the discretion of Waterfall Partners, who may decide to delay distributions for any reason, including maintenance or capital reserves."},{"insert":"\n\n"},{"attributes":{"color":"#202020","font":"Nunito Sans","size":"13.5pt"},"insert":"Waterfall Partners"},{"attributes":{"color":"red","font":"Nunito Sans","size":"13.5pt"},"insert":" "},{"attributes":{"color":"#202020","font":"Nunito Sans","size":"13.5pt"},"insert":"will receive a promoted/carried interest as indicated above in the form of Class B Member Units."},{"insert":"\n\n"}]