Skip to content

Commit dd87d7f

Browse files
authored
feat(push): preserve null (#362)
* feat(push): preserve null * feat(push): preserve for oracle * feat(push): update with preserve null for pg and oracle * fix(push): lint error * test(preserve): use lino query to set value * test(preserve): use lino query to set value * test(preserve): change yaml configuration * test(preserve): assert that others columns are updated * chore: add gci linter * feat(preserve): oracle support * feat(preserve): add preserve methods for DB2 Dialect
1 parent 71b665f commit dd87d7f

21 files changed

+819
-30
lines changed

CHANGELOG.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,17 @@ Types of changes
1414
- `Fixed` for any bug fixes.
1515
- `Security` in case of vulnerabilities.
1616

17+
## [3.4.0]
18+
19+
- `Added` preserve NULL or empty functionality in "lino push update" command
20+
1721
## [3.3.0]
1822

19-
- `Added` configurable CORS support to http command
23+
- `Added` configurable CORS support to http command
2024

2125
## [3.2.1]
2226

23-
- `Fixed`Filter Parameter Not Working in "lino http" Command [#369](https://github.com/CGI-FR/LINO/issues/369)
27+
- `Fixed` Filter Parameter Not Working in "lino http" Command [#369](https://github.com/CGI-FR/LINO/issues/369)
2428

2529
## [3.2.0]
2630

README.md

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -375,6 +375,52 @@ tables:
375375
bytes: true
376376
```
377377

378+
### Preserving `null` Values in the Database
379+
380+
In some use cases, it's important to **preserve `null` values that already exist in the database**, even when incoming data provides a new value. For example, a `null` might indicate intentionally missing or incomplete information that should not be overwritten automatically.
381+
382+
Lino supports this behavior through the `preserve` setting, which can be configured per column in the `tables.yaml` file. To preserve `null` values, you can define:
383+
384+
```yaml
385+
columns:
386+
- name: address2
387+
export: string
388+
preserve: "null"
389+
```
390+
391+
With this setting, if the **current value in the database is `null`**, Lino will **skip the update for this column only**, even if the input JSON contains a non-null value. This allows you to maintain the integrity of `null` values that have semantic meaning.
392+
393+
⚠️ **Important:**
394+
Only the preserved column is skipped — other columns in the same record **will still be updated as usual** if their values change.
395+
396+
The same logic applies to other preservation modes:
397+
398+
* `preserve: empty` skips updates if the current DB value is an **empty string (`""`)**.
399+
* `preserve: blank` skips updates if the current DB value is **`null`**, **empty**, or consists of **only whitespace** (e.g. `" "`).
400+
401+
The table below summarizes how `preserve` behaves based on the current database value and the incoming JSON input:
402+
403+
404+
| Preserve | Current DB Value | JSON Input | Final DB Value | Explanation |
405+
|------------|------------------|-------------|----------------|------------------------------------------------------------------------------------------------|
406+
| **default**| `NULL` | `"new"` | `"new"` | No preservation, update applies even if DB is null. |
407+
| **default**| `""` | `"new"` | `"new"` | No preservation, update applies even if DB is empty string. |
408+
| **default**| `" "` | `"new"` | `"new"` | No preservation, update applies even if DB is blank spaces. |
409+
| **default**| `"old"` | `"new"` | `"new"` | Normal update applies. |
410+
| **null** | `NULL` | `"new"` | `NULL` | Preserve null: keep DB null even if new value is real. |
411+
| **null** | `" "` | `"new"` | `"new"` | DB is not null, update applies. |
412+
| **null** | `""` | `"new"` | `"new"` | DB is not null, update applies. |
413+
| **empty** | `""` | `"new"` | `""` | Preserve empty string: keep empty string in DB, ignore new value. |
414+
| **empty** | `NULL` | `"new"` | `"new"` | DB is null, not empty string → update applies. |
415+
| **empty** | `" "` | `"new"` | `"new"` | DB is blank spaces, not empty string → update applies. |
416+
| **blank** | `NULL` | `"new"` | `NULL` | Preserve blank: null is considered blank, preserve it. |
417+
| **blank** | `""` | `"new"` | `""` | Preserve blank: empty string preserved. |
418+
| **blank** | `" "` | `"new"` | `" "` | Preserve blank: spaces-only string preserved. |
419+
| **blank** | `"old"` | `"new"` | `"new"` | Not blank, update applies. |
420+
421+
⚠️ **Important:**
422+
This feature is only supported for Oracle and PostgreSQL databases.
423+
378424
### How to update primary key
379425

380426
Let's say you have this record in database :

demo/store.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{"address_id":2,"last_update":"2006-02-15T09:57:12Z","manager_staff_id":1,"store_id":1}

internal/app/push/cli.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -320,7 +320,7 @@ func (c idToPushConverter) getTable(name string, autoTruncate bool) push.Table {
320320

321321
columns := []push.Column{}
322322
for _, col := range table.Columns {
323-
columns = append(columns, push.NewColumn(col.Name, col.Export, col.Import, col.DBInfo.Length, col.DBInfo.ByteBased, autoTruncate))
323+
columns = append(columns, push.NewColumn(col.Name, col.Export, col.Import, col.DBInfo.Length, col.DBInfo.ByteBased, autoTruncate, col.Preserve))
324324
}
325325

326326
return push.NewTable(table.Name, table.Keys, push.NewColumnList(columns))

internal/infra/push/datadestination_db2.go

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -115,9 +115,11 @@ func (d Db2Dialect) UpdateStatement(tableName string, selectValues []ValueDescri
115115

116116
headers = append(headers, column)
117117

118-
sql.WriteString(column.name)
119-
sql.WriteString("=")
120-
sql.WriteString(d.Placeholder(index + 1))
118+
errColumn := appendColumnToSQL(column, sql, d, index)
119+
if errColumn != nil {
120+
return "", nil, errColumn
121+
}
122+
121123
if index+1 < len(selectValues) {
122124
sql.WriteString(", ")
123125
}
@@ -172,3 +174,18 @@ func (d Db2Dialect) DisableConstraintStatement(tableName string, constraintName
172174
func (d Db2Dialect) EnableConstraintStatement(tableName string, constraintName string) string {
173175
panic(fmt.Errorf("Not implemented"))
174176
}
177+
178+
func (d Db2Dialect) SupportPreserve() []string {
179+
return []string{
180+
string(push.PreserveNothing),
181+
}
182+
}
183+
184+
// BlankTest implements SQLDialect.
185+
func (d Db2Dialect) BlankTest(name string) string {
186+
panic("unimplemented")
187+
}
188+
189+
func (d Db2Dialect) EmptyTest(name string) string {
190+
panic("unimplemented")
191+
}

internal/infra/push/datadestination_db2_dummy.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,3 +97,18 @@ func (d Db2Dialect) DisableConstraintStatement(tableName string, constraintName
9797
func (d Db2Dialect) EnableConstraintStatement(tableName string, constraintName string) string {
9898
panic(fmt.Errorf("Not implemented"))
9999
}
100+
101+
func (d Db2Dialect) SupportPreserve() []string {
102+
return []string{
103+
string(push.PreserveNothing),
104+
}
105+
}
106+
107+
// BlankTest implements SQLDialect.
108+
func (d Db2Dialect) BlankTest(name string) string {
109+
panic("unimplemented")
110+
}
111+
112+
func (d Db2Dialect) EmptyTest(name string) string {
113+
panic("unimplemented")
114+
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
// Copyright (C) 2021 CGI France
2+
//
3+
// This file is part of LINO.
4+
//
5+
// LINO is free software: you can redistribute it and/or modify
6+
// it under the terms of the GNU General Public License as published by
7+
// the Free Software Foundation, either version 3 of the License, or
8+
// (at your option) any later version.
9+
//
10+
// LINO is distributed in the hope that it will be useful,
11+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
12+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13+
// GNU General Public License for more details.
14+
//
15+
// You should have received a copy of the GNU General Public License
16+
// along with LINO. If not, see <http://www.gnu.org/licenses/>.
17+
18+
//go:build db2
19+
// +build db2
20+
21+
package push
22+
23+
import (
24+
"strings"
25+
"testing"
26+
27+
"github.com/cgi-fr/lino/pkg/push"
28+
_ "github.com/ibmdb/go_ibm_db"
29+
"github.com/stretchr/testify/assert"
30+
)
31+
32+
func TestAppendColumnToSQLWithPreserveBlank(t *testing.T) {
33+
sql := &strings.Builder{}
34+
column := ValueDescriptor{
35+
name: "column",
36+
column: push.NewColumn(
37+
"column",
38+
"",
39+
"",
40+
0,
41+
false,
42+
false,
43+
44+
push.PreserveBlank,
45+
),
46+
}
47+
48+
err := appendColumnToSQL(column, sql, Db2Dialect{}, 0)
49+
assert.NotNil(t, err)
50+
}

internal/infra/push/datadestination_mariadb.go

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -112,9 +112,11 @@ func (d MariadbDialect) UpdateStatement(tableName string, selectValues []ValueDe
112112

113113
headers = append(headers, column)
114114

115-
sql.WriteString(column.name)
116-
sql.WriteString("=")
117-
sql.WriteString(d.Placeholder(index + 1))
115+
errColumn := appendColumnToSQL(column, sql, d, index)
116+
if errColumn != nil {
117+
return "", nil, errColumn
118+
}
119+
118120
if index+1 < len(selectValues) {
119121
sql.WriteString(", ")
120122
}
@@ -164,3 +166,18 @@ func (d MariadbDialect) DisableConstraintStatement(tableName string, constraintN
164166
func (d MariadbDialect) EnableConstraintStatement(tableName string, constraintName string) string {
165167
panic(fmt.Errorf("Not implemented"))
166168
}
169+
170+
func (d MariadbDialect) SupportPreserve() []string {
171+
return []string{
172+
string(push.PreserveNothing),
173+
}
174+
}
175+
176+
// BlankTest implements SQLDialect.
177+
func (d MariadbDialect) BlankTest(name string) string {
178+
panic("unimplemented")
179+
}
180+
181+
func (d MariadbDialect) EmptyTest(column string) string {
182+
return fmt.Sprintf("%s = ''", column)
183+
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
// Copyright (C) 2021 CGI France
2+
//
3+
// This file is part of LINO.
4+
//
5+
// LINO is free software: you can redistribute it and/or modify
6+
// it under the terms of the GNU General Public License as published by
7+
// the Free Software Foundation, either version 3 of the License, or
8+
// (at your option) any later version.
9+
//
10+
// LINO is distributed in the hope that it will be useful,
11+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
12+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13+
// GNU General Public License for more details.
14+
//
15+
// You should have received a copy of the GNU General Public License
16+
// along with LINO. If not, see <http://www.gnu.org/licenses/>.
17+
18+
package push
19+
20+
import (
21+
"strings"
22+
"testing"
23+
24+
"github.com/cgi-fr/lino/pkg/push"
25+
"github.com/stretchr/testify/assert"
26+
)
27+
28+
func TestAppendColumnToSQLMariaDBWithPreserveBlank(t *testing.T) {
29+
t.Parallel()
30+
sql := &strings.Builder{}
31+
column := ValueDescriptor{
32+
name: "column",
33+
column: push.NewColumn(
34+
"column",
35+
"",
36+
"",
37+
0,
38+
false,
39+
false,
40+
41+
push.PreserveBlank,
42+
),
43+
}
44+
45+
err := appendColumnToSQL(column, sql, MariadbDialect{}, 0)
46+
assert.NotNil(t, err)
47+
}
48+
49+
func TestAppendColumnToSQLMariaDB(t *testing.T) {
50+
t.Parallel()
51+
sql := &strings.Builder{}
52+
column := ValueDescriptor{
53+
name: "column",
54+
column: push.NewColumn(
55+
"column",
56+
"",
57+
"",
58+
0,
59+
false,
60+
false,
61+
62+
push.PreserveNothing,
63+
),
64+
}
65+
66+
err := appendColumnToSQL(column, sql, MariadbDialect{}, 0)
67+
assert.Nil(t, err)
68+
69+
assert.Equal(t, "column=?", sql.String())
70+
}

internal/infra/push/datadestination_oracle.go

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,7 @@ func (d OracleDialect) UpdateStatement(tableName string, selectValues []ValueDes
138138
for _, pk := range whereValues {
139139
if column.name == pk.name {
140140
isInWhere = true
141+
141142
break
142143
}
143144
}
@@ -148,17 +149,21 @@ func (d OracleDialect) UpdateStatement(tableName string, selectValues []ValueDes
148149

149150
headers = append(headers, column)
150151

151-
sql.WriteString(column.name)
152-
sql.WriteString("=")
153-
sql.WriteString(d.Placeholder(index + 1))
152+
errColumn := appendColumnToSQL(column, sql, d, index)
153+
if errColumn != nil {
154+
return "", nil, errColumn
155+
}
156+
154157
if index+1 < len(selectValues) {
155158
sql.WriteString(", ")
156159
}
157160
}
158161
if len(whereValues) > 0 {
159162
sql.WriteString(" WHERE ")
160163
} else {
161-
return "", nil, &push.Error{Description: fmt.Sprintf("can't update table [%s] because no primary key is defined", tableName)}
164+
return "", nil, &push.Error{
165+
Description: fmt.Sprintf("can't update table [%s] because no primary key is defined", tableName),
166+
}
162167
}
163168
for index, pk := range whereValues {
164169
headers = append(headers, pk)
@@ -243,3 +248,20 @@ func (d OracleDialect) EnableConstraintStatement(tableName string, constraintNam
243248
sql.WriteString(constraintName)
244249
return sql.String()
245250
}
251+
252+
func (d OracleDialect) SupportPreserve() []string {
253+
return []string{
254+
string(push.PreserveNothing),
255+
string(push.PreserveNull),
256+
string(push.PreserveBlank),
257+
}
258+
}
259+
260+
// BlankTest implements SQLDialect.
261+
func (d OracleDialect) BlankTest(column string) string {
262+
return fmt.Sprintf("TRIM(%s) IS NULL", column)
263+
}
264+
265+
func (d OracleDialect) EmptyTest(column string) string {
266+
return fmt.Sprintf("%s IS NULL", column)
267+
}

0 commit comments

Comments
 (0)